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.
Recommended Construction¶
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()
When to use
- You are in a non-coroutine context (a Java caller, a servlet thread, a CLI
main). - You want to control the
Executorthat 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
nullwhile siblings still resolve. - Errors in non-nullable fields may bubble up and null out parents.
You should:
- Always serialize both
"data"(possiblynull) 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
nullwhen no scopes are configured for the schema. - Returns the set of scope IDs applied to the schema when it is scoped.