Skip to content

Viaduct API

This document describes how to use the runtime entry point viaduct.service.api.Viaduct interface to execute GraphQL operations, select schema variants, and interpret execution results.

What Viaduct Is

A Viaduct instance is the main entry point for executing GraphQL operations against the Viaduct runtime. It is intended to be created once at service startup and reused for all requests.

You typically create an instance via:

  • BasicViaductFactory — simplest way to build a working engine with defaults.
  • ViaductBuilder — full control over SPI hooks (metrics, error reporting, flags, schema configuration, etc.).

Creating A Viaduct Instance

Option A: BasicViaductFactory

This is the simplest way to build an instance of Viaduct.

import viaduct.service.BasicViaductFactory
import viaduct.service.api.Viaduct

val viaduct: Viaduct = BasicViaductFactory.create()

When to use

  • Your service is “off-the-shelf” or you’re getting started.
  • You don’t need custom metrics, flags, custom exception handlers, custom error shaping, etc.

Option B: ViaductBuilder

ViaductBuilder provides full control of all the internal configurations of the Viaduct.

import viaduct.service.SchemaScopeInfo
import viaduct.service.ViaductBuilder

val viaduct =
  ViaductBuilder()
    // Register scoped schemas discovered from classpath resources.
    .withScopedSchemas(
      listOf(
        SchemaScopeInfo(id = "public", scopesToApply = setOf("public"))
      )
    )
    // Optional platform hooks:
    // .withMeterRegistry(meterRegistry)
    // .withFlagManager(flagManager)
    // .withResolverErrorReporter(errorReporter)
    // .withDataFetcherErrorBuilder(resolverErrorBuilder)
    // .withGlobalIDCodec(globalIdCodec)
    .build()

When to use

  • You need to control observability, error reporting/shaping, feature flags, schema configuration, or global-id behavior.
  • You want a single consistent “production-like” configuration used by both prod and test harnesses.

Choosing The Schema: SchemaId

Each execution targets a schema variant identified by a SchemaId:

  • SchemaId.Base — default unscoped external schema.
  • SchemaId.Scoped(id, scopeIds) — a schema variant derived from the full schema by applying scope IDs.
  • SchemaId.None — sentinel “non-existent schema” (typically not used for normal execution).
import viaduct.service.api.SchemaId

val base = SchemaId.Base

val internalOnly = SchemaId.Scoped(
  id = "INTERNAL",
  scopeIds = setOf("internal", "admin")
)

How Scoped Schemas Relate To Access Control

A scoped schema is a structural way to hide types/fields from a client by removing them from the executable schema. This is complementary to runtime checks inside resolvers.

To implement or audit access-control, you can ask Viaduct what scopes were applied for a schema.

Building An ExecutionInput

ExecutionInput is a container for:

  • operationText — GraphQL document text.
  • operationName — nullable, required only when the document contains multiple operations.
  • variables — variable map used by the operation.
  • operationId — stable identifier for instrumentation (auto-generated by default).
  • executionId — unique request identifier (auto-generated by default).
  • requestContext — arbitrary, deployment-specific context object exposed to execution contexts.

Prefer the builder for clarity and explicitness:

import viaduct.service.api.ExecutionInput

val input =
  ExecutionInput.builder()
    .operationText(
      """
      query User(${"$"}id: ID!) {
        user(id: ${"$"}id) { id name }
      }
      """.trimIndent()
    )
    .variables(mapOf("id" to "123"))
    .requestContext(MyRequestContext(requestId = "req-1", auth = authInfo))
    .build()

Convenience Factory

For simple cases:

val input =
  ExecutionInput.create(
    operationText = "query { health }",
    variables = emptyMap(),
    requestContext = null
  )

Notes

  • If you don’t provide operationId, Viaduct generates one from the operation text and name (a hash-like value).
  • If you don’t provide executionId, Viaduct generates a random UUID string.

Executing Operations

Viaduct exposes these methods:

interface Viaduct {
  // Kotlin / coroutine entry point: suspends until the operation completes.
  suspend fun execute(input: ExecutionInput, schemaId: SchemaId = SchemaId.Base): ExecutionResult

  // Java-friendly entry point: returns a future and runs on the given Executor
  // (defaults to ForkJoinPool.commonPool()).
  fun executeAsync(
    input: ExecutionInput,
    schemaId: SchemaId = SchemaId.Base,
    executor: Executor = ForkJoinPool.commonPool(),
  ): CompletableFuture<ExecutionResult>
  fun getAppliedScopes(schemaId: SchemaId): Set<String>?
}

In both cases the returned ExecutionResult.errors are sorted by GraphQL path and then message.

execute(...)

execute is a suspend function that returns the ExecutionResult directly. It is the idiomatic entry point for Kotlin and coroutine-based servers, and it runs in the caller's coroutine context.

import viaduct.service.api.SchemaId

suspend fun handleRequest(viaduct: Viaduct, input: ExecutionInput): Map<String, Any?> {
  val result = viaduct.execute(input, SchemaId.Base)
  return result.toSpecification()
}

When to use

  • You’re already in a coroutine (suspend) context, or can launch one (runBlocking, etc.).
  • You want execution to inherit the caller’s coroutine context.

executeAsync(...)

executeAsync is a non-suspending function that returns a CompletableFuture<ExecutionResult>. Following the standard Java idiom, it accepts an Executor that controls the threads execution runs on (defaulting to ForkJoinPool.commonPool()). It is the idiomatic entry point for Java callers and other non-coroutine contexts.

val result = viaduct.executeAsync(input, SchemaId.Base).join()
val response = result.toSpecification()
// From Java
ExecutionResult result = viaduct.executeAsync(input, SchemaId.Base.INSTANCE).join();

When to use

  • You are in a non-coroutine context (a Java caller, a servlet thread, a CLI main).
  • You want to control the Executor that runs the operation.

Interpreting ExecutionResult

ExecutionResult is Viaduct’s wrapper (it does not expose GraphQL Java types directly):

  • getData(): Map<String, Any?>?
  • errors: List<GraphQLError> (sorted by path then message)
  • extensions: Map<Any, Any?>?
  • toSpecification(): Map<String, Any?> (ready to JSON-serialize for HTTP)

Data vs Errors

GraphQL supports partial results:

  • A nullable field can error and become null while siblings still resolve.
  • Errors in non-nullable fields may bubble up and null out parents.

You should:

  • Always serialize both "data" (possibly null) and "errors" when errors exist.
  • Treat errors.isNotEmpty() as “something went wrong” even if data exists.

getAppliedScopes(schemaId)

This is primarily for authorization / access-control and auditing:

val scopes: Set<String>? = viaduct.getAppliedScopes(schemaId)

if (scopes == null) {
  // base schema (no scopes)
} else if ("internal" !in scopes) {
  // reject or downgrade capabilities
}

Semantics

  • Returns null when no scopes are configured for the schema.
  • Returns the set of scope IDs applied to the schema when it is scoped.