Skip to content

GraphQL Driver

The GraphQL driver adapts generated GraphQL endpoints to GraphQL-over-HTTP.

Install it:

Terminal window
go get github.com/TDB-Group/anvil-graphql

Import it as:

import anvilgraphql "github.com/TDB-Group/anvil-graphql"
app := anvil.New(
anvilgraphql.Driver(),
)

When installed with HTTP and gRPC drivers, Anvil’s edge listener selects the GraphQL driver by exact request path. The driver then decodes the GraphQL HTTP payload and runs the generated endpoint.

The driver also has standalone Start and ServeConn paths for tests and low-level integrations. In a normal multi-protocol app, Anvil runs the public listener and calls this driver as an http.Handler.

app := anvil.New(
anvilgraphql.Driver(anvilgraphql.Options{
MaxBodyBytes: 8 << 20,
MuxPath: "/graphql",
}),
)

Supported options:

OptionBehavior
ServerCloned *http.Server settings for standalone driver use. Handler must be nil.
ErrorPipelineError mapper and observer pipeline. Driver fills this from app.ErrorPipeline() when the option is nil.
MaxBodyBytesMaximum POST body size. 0 selects the driver default of 4 MiB; negative values are rejected.
MuxPathPath reported to Anvil’s edge before generated routes are registered. Defaults to /graphql. Once generated routes are registered, the driver reports the sorted unique set of registered GraphQL route paths instead.

Nil ErrorPipeline options are replaced with a default pipeline before validation, or with the owning app pipeline when installed through Driver. The driver rejects negative timeouts, negative body limits, a server with a pre-filled Handler, and more than one options value.

DriverFrom installs an existing transport and sets that transport’s error pipeline to the owning app pipeline. Use anvilgraphql.New(anvilgraphql.Options{ErrorPipeline: ...}) when the transport is running standalone or when a test needs to inspect the transport before it is installed.

MuxPath is trimmed. An empty value becomes /graphql; a non-empty value without a leading slash receives one.

MaxBodyBytes uses http.MaxBytesReader for POST bodies. The option cannot disable the default limit by setting 0; 0 means “use the default.”

Server is for standalone driver use. In a normal Anvil app, public listener settings come from anvil.WithListener because the edge runtime serves the shared HTTP-family listener.

When a custom Server leaves timeout fields empty, the driver applies these defaults to the cloned server:

Server settingDefault
ReadHeaderTimeout5s
ReadTimeout30s
WriteTimeout30s
IdleTimeout2m

The standalone GraphQL driver does not have its own TLS option. Use Anvil’s ListenTLS/RunTLS for normal applications, or terminate TLS before the standalone server.

GraphQL endpoints embed sdk.GraphQLEndpoint or sdk.GraphQLEndpointWith[Policy]:

type ProjectGraph struct {
sdk.GraphQLEndpointWith[GraphPolicy] `path:"/graphql"`
Store project.Store `inject:"read"`
}

Resolvers implement Execute:

func (g *ProjectGraph) Execute(
ctx context.Context,
req sdk.GraphQLRequest,
) (sdk.GraphQLResponse, error)

Subscriptions are optional:

func (g *ProjectGraph) Subscribe(
ctx context.Context,
req sdk.GraphQLRequest,
stream sdk.GraphQLSubscriptionStream,
) error

An endpoint path is still part of the group tree. If a GraphQL endpoint is nested under /api and /v1, path:"/graphql" becomes /api/v1/graphql.

The driver matches the final request path exactly. It does not inspect the GraphQL body during edge classification and it does not perform route fallback after a path is selected.

Registration fails when the path is empty, the handler is nil, the middleware value does not implement sdk.GraphQLMiddleware, or another GraphQL route has already registered the same path.

RegisterGraphQL stores route.Path exactly as supplied. It does not trim spaces, add a leading slash, lowercase, or clean trailing slashes. Generated Anvil code passes final compiler paths. The helper anvilgraphql.Route(...) normalizes its own path argument before it creates an sdk.GraphQLRoute.

The driver exposes a small adapter contract for schema engines:

type Executor interface {
Execute(context.Context, sdk.GraphQLRequest) (sdk.GraphQLResponse, error)
}
type Subscriber interface {
Subscribe(context.Context, sdk.GraphQLRequest, sdk.GraphQLSubscriptionStream) error
}

anvilgraphql.Route(path, endpoint, executor) converts an executor into sdk.GraphQLRoute. If the executor also implements Subscriber, the generated route gets a subscription handler too.

Route trims the path, converts an empty path to /graphql, and adds a leading slash when the path has none. It rejects a nil executor and an empty endpoint name.

ExecutorFunc and SubscriberFunc adapt plain functions. They return errors when called on nil functions, so nil wiring fails as an ordinary Go error.

The driver accepts GET and POST.

GET reads:

  • query
  • operationName
  • variables, decoded as a JSON object or null when present
  • extensions, decoded as a JSON object or null when present

POST reads JSON:

{
"query": "query Project($id: ID!) { project(id: $id) { id name } }",
"operationName": "Project",
"variables": {"id": "proj_123"},
"extensions": {}
}

query is required for both methods. variables and extensions must decode to JSON objects or null when present. Unsupported methods return only 405 Method Not Allowed; the driver does not build a GraphQL error body or publish an Anvil error event for that method check.

POST decodes the request body as JSON and enforces MaxBodyBytes with http.MaxBytesReader. Invalid JSON, missing body, invalid query parameters, and missing query are mapped as 400 Bad Request GraphQL errors with public message invalid graphql request.

The POST decoder reads one JSON request object into query, operationName, variables, and extensions. Extra fields are ignored by the JSON decoder.

The driver does not require a specific Content-Type before decoding a POST body. The body still has to be valid GraphQL JSON.

Responses are encoded as GraphQL HTTP JSON:

{
"data": {},
"errors": [],
"extensions": {}
}

GraphQL handler errors are mapped through Anvil’s error pipeline. The driver returns a GraphQL error response with the mapped HTTP status and also stores the status in errors[].extensions.status.

The GraphQL driver always writes application/json for normal GraphQL responses. It does not negotiate response codecs through Accept; Accept is only used to select SSE subscriptions.

If JSON encoding of the GraphQL response fails, the driver writes HTTP 500 with a fixed GraphQL error body. That bypasses the normal mapper because the response encoder is already the failing component.

Panics from GraphQL execution, subscription handlers, and GraphQL middleware are recovered and mapped as handler-phase errors.

When the route has a subscription handler and any request Accept header value contains the case-sensitive literal substring text/event-stream, the driver uses server-sent events. It does not parse Accept quality values for this decision.

Subscriptions use SSE. The GraphQL driver does not implement WebSocket-based GraphQL subscriptions.

For normal query/mutation execution, ctx.Subscription() is nil. For SSE subscription execution, ctx.Subscription() is the stream passed to the subscription handler.

Subscription events are emitted as:

  • next for payloads sent through stream.Send
  • error when a mapped error happens after the stream starts
  • complete when the subscription finishes after starting

When the stream starts, the driver writes HTTP 200 with Content-Type: text/event-stream, sets Cache-Control: no-cache, sets Connection: keep-alive, disables proxy buffering with X-Accel-Buffering: no, and flushes the response. Each stream.Send payload is encoded through the same GraphQL HTTP JSON response shape used by normal queries.

If the response writer does not support flushing, subscriptions fail with a 503 Anvil failure.

The driver starts the SSE stream immediately before it calls the generated Subscribe handler. Middleware that fails before calling ctx.Next() can still produce a normal GraphQL error response. Errors returned by Subscribe, or by middleware after ctx.Next() has started the stream, are emitted as error events and then the stream completes.

If the route has no subscription handler, an Accept: text/event-stream request still runs the normal Execute handler.

GraphQL middleware uses HandleGraphQL:

func (GraphQLTrace) HandleGraphQL(ctx sdk.GraphQLCtx) (sdk.GraphQLResponse, error) {
req := ctx.Request()
_ = req
response, err := ctx.Next()
if err != nil {
return response, err
}
if response.Extensions == nil {
response.Extensions = map[string]any{}
}
response.Extensions["trace"] = "enabled"
return response, nil
}

HandleGraphQL is the only GraphQL middleware method. It wraps the generated Execute and Subscribe calls and works with sdk.GraphQLRequest and sdk.GraphQLResponse, not with raw HTTP response bodies. Put setup before ctx.Next() and response or error work after ctx.Next() returns.

Use GraphQL middleware when the code needs GraphQL operation data, response extensions, or subscription stream state. Use HTTP middleware for REST and WebSocket routes. GraphQL arrives over HTTP, but Anvil classifies it by the registered GraphQL path and sends it to the GraphQL driver before normal HTTP route matching.

Attach shared GraphQL middleware to a parent group:

type V1 struct {
sdk.Group `path:"/v1"`
_ sdk.Use[middleware.GraphQLTrace]
ProjectGraph *graph.ProjectGraph
}

Attach endpoint-specific GraphQL middleware through sdk.GraphQLEndpointWith[Policy]:

type GraphPolicy struct {
_ sdk.Use[middleware.GraphQLAudit]
}

That policy type is the GraphQL equivalent of an HTTP route policy such as sdk.GETWith[ReadPolicy]. Keep endpoint-specific middleware in the policy type, not as anonymous sdk.Use[...] fields on the endpoint struct.

Place sdk.Use[...] on a group or policy type. The compiler rejects middleware fields directly inside a GraphQL endpoint struct so the route tree stays clear.

GraphQL does not use HTTP middleware even though GraphQL is served over HTTP. The GraphQL context exposes GraphQL request data, subscription stream access, and a GraphQL response shape. Return a mapped error when a GraphQL-specific failure needs normalization.

Middleware runs in inherited group order, then endpoint policy order. The first middleware receives the operation, its ctx.Next() calls the next middleware, and the final continuation calls Execute or Subscribe. Returning without ctx.Next() stops the resolver. That is useful for auth, persisted-query checks, operation limits, or cached GraphQL responses.

ctx.Next() can be called once by one middleware invocation. Calling it twice, or calling it when no middleware continuation is installed, returns an internal Anvil failure.

GraphQL middleware and resolver panics are recovered as handler-phase errors. That means observers receive a mapped failure, but event.Recovered is false because the final phase is handler, not panic. The public GraphQL response still contains the mapped error and extensions.status.

Read Middleware for execution order, sdk.GraphQLCtx, and compiler diagnostics.