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.
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 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
suspendso 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.errorsare 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.
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.