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.TenantRegistrationInfo
import viaduct.service.api.Viaduct
import viaduct.service.api.spi.TenantCodeInjector

val tenantInfo = TenantRegistrationInfo(
  tenantPackagePrefix = "com.example.myservice", // where tenant-generated code lives
  tenantCodeInjector = TenantCodeInjector.Naive  // or your DI-backed injector
)

val viaduct: Viaduct =
  BasicViaductFactory.create(
    tenantRegistrationInfo = tenantInfo,
    // optional schemaRegistrationInfo override (defaults are usually correct)
  )

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.ViaductBuilder
import viaduct.service.runtime.SchemaConfiguration

val viaduct =
  ViaductBuilder()
    .withSchemaConfiguration(
      SchemaConfiguration.fromResources(
        // scopes, GRT resource discovery, etc.
      )
    )
    // Optional platform hooks:
    // .withMeterRegistry(meterRegistry)
    // .withFlagManager(flagManager)
    // .withResolverErrorReporter(errorReporter)
    // .withDataFetcherErrorBuilder(resolverErrorBuilder)
    // .withDataFetcherExceptionHandler(exceptionHandler)
    // .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.Full — default “complete” 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 full = SchemaId.Full

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 three methods:

interface Viaduct {
  suspend fun executeAsync(input: ExecutionInput, schemaId: SchemaId = SchemaId.Full): CompletableFuture<ExecutionResult>
  fun execute(input: ExecutionInput, schemaId: SchemaId = SchemaId.Full): ExecutionResult
  fun getAppliedScopes(schemaId: SchemaId): Set<String>?
}

executeAsync(...)

Use executeAsync in coroutine-based servers or anywhere you want a non-blocking API.

  • The function is suspend so it can capture the current coroutine context.
  • It returns a CompletableFuture<ExecutionResult> to interoperate with GraphQL Java and Java-based instrumentation stacks.
  • The returned ExecutionResult.errors are sorted by GraphQL path and then message.

Typical usage:

import viaduct.service.api.SchemaId

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

// Small helper to await a CompletableFuture from coroutines
suspend fun <T> java.util.concurrent.CompletableFuture<T>.await(): T =
  kotlinx.coroutines.future.await()

When to use - You’re already in a coroutine (suspend) context. - You want to avoid blocking request threads while the engine executes.

execute(...)

execute is a blocking wrapper around executeAsync(...).join().

Use it when you are in a fully synchronous entry point (rare in modern Kotlin services), or in small tooling.

val result = viaduct.execute(input, SchemaId.Full)
val response = result.toSpecification()

Caution - In a server, avoid calling execute on event-loop threads or limited thread pools.

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) {
  // full 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.