@setu-ts/graphql-plugin

GraphQL plugin for the Setu-TS framework. Provides schema-first and code-first GraphQL support over the kernel router.

Features

  • Schema-first construction (SDL + resolver map)
  • Code-first construction (application-built schema)
  • GraphQL-over-HTTP transport with media-type negotiation
  • Bounded parse+validate document cache
  • Error masking, query-depth limiting, introspection control
  • GraphiQL UI for development
  • Health indicator integration

Installation

deno add npm:graphql npm:@setu-ts/graphql-plugin

Usage

Plugins are passed to createApplication() — there is no new Application() / app.use() API.

Schema-First

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    GraphqlPlugin({
      typeDefs: `
        type Query {
          hello(name: String!): String
        }
      `,
      resolvers: {
        Query: {
          hello: (_, { name }) => `Hello, ${name}!`,
        },
      },
    }),
  ],
});

await app.start({ port: 3000 });

Code-First

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';
import { buildSchema } from 'graphql';

const schema = buildSchema(`
  type Query {
    hello(name: String!): String
  }
`);

const app = createApplication({
  plugins: [RuntimePlugin(), GraphqlPlugin({ schema })],
});

await app.start({ port: 3000 });

Injecting Your Own graphql Module

If your application uses its own copy of graphql, inject it to avoid cross-copy issues:

import { adaptGraphqlModule, GraphqlPlugin } from '@setu-ts/graphql-plugin';
import { buildSchema } from 'graphql';
import * as graphql from 'graphql';

GraphqlPlugin({
  schema: buildSchema('type Query { hello: String }'),
  graphqlModule: adaptGraphqlModule(graphql),
});

Options

OptionTypeDefaultDescription
typeDefsstring-SDL schema definition (schema-first mode)
resolversResolverMap-Resolver map (schema-first mode)
schemaGraphqlSchemaLike-Pre-built schema (code-first mode)
pathstring/graphqlEndpoint path
graphiqlbooleantrueEnable GraphiQL UI
introspectionbooleantrueEnable schema introspection
maxDepthnumber10Maximum query depth — nesting only (0 to disable)
maxNodesnumber0Maximum fields a document’s largest operation may resolve, aliases counted separately and fragment spreads expanded at each site. 0 (the default) is unbounded
validationRulesunknown[][]Additional validation rules
maskInternalErrorsbooleantrueMask internal server errors
formatError(error) => error-Custom error formatter
documentCacheSizenumber1000Max cached documents (0 to disable)
buildContext(input) => context-Custom context builder
rootValueunknown-Root value for resolvers
graphqlModuleGraphqlModuleLike-Injected graphql module

Platform Notes

Deno

The graphql package reads process.env.NODE_ENV at import time. If you run into permission errors, add the --allow-env flag:

deno run --allow-env --allow-net main.ts

Cloudflare Workers

Enable nodejs_compat in your wrangler.toml:

compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

Subscriptions

Subscriptions are opt-in. Without a subscriptions option no transport route is registered, and POST/GET /graphql continues to refuse a subscription operation with 400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP.

GraphqlPlugin({
  typeDefs,
  resolvers,
  subscriptions: {
    // WebSocket registers only when CAPABILITIES.WEBSOCKET is present and the
    // adapter can upgrade; otherwise the plugin logs a notice and carries on.
    websocket: {
      onConnect: (info) => {
        const token = info.connectionParams?.authorization;
        if (!isValid(token)) return false; // closes 4403: Forbidden
        info.data.set('user', userFor(token)); // read back by the resolver context
      },
    },
    sse: { heartbeatMs: 15_000 },
  },
});

Endpoints default to the GraphQL path plus /ws and /stream, so path: '/api/graphql' gives /api/graphql/ws and /api/graphql/stream. Both transports take their timers from IRuntimeServices, so subscriptions requires CAPABILITIES.RUNTIME and throws at registration without it.

A schema-first subscription field takes a { subscribe, resolve? } entry — subscribe returns the async iterable, and graphql reads the event source from there:

const resolvers: ResolverMap = {
  Subscription: {
    tick: {
      subscribe: () => tickStream(),
      resolve: (payload) => (payload as { tick: number }).tick,
    },
  },
};

onConnect and a route guard cannot both authenticate the socket

connectionParams is the protocol’s auth channel, and onConnect is where you read it — but onConnect runs on connection_init, which is a message on an already-open socket. Since M70a every inbound request, upgrade included, runs the middleware pipeline BEFORE the handshake, so a guard covering the /graphql prefix refuses GET /graphql/ws with 401 and onConnect never runs. Following this section and a pipeline guard at the same time cannot work, and the failure is silent from the client’s side: an ordinary 401, with nothing naming the interaction.

Pick one:

  • Authenticate in onConnect and keep the socket path out of the guard — scope the guard to the HTTP endpoint (/graphql exactly, not the prefix) or exclude /graphql/ws. This is the option a browser client usually needs, because a WebSocket constructor can set no headers, so connectionParams is the only credential channel it has.
  • Authenticate on the upgrade and let the guard do the work, using a credential a browser CAN put on an upgrade — a cookie, via auth-plugin’s session strategy (M73) — or a header when the client is a server. onConnect then needs no token check, and ctx.request.user is already populated when the handshake is decided.

Do not read a guard’s 401 on the socket path as a broken onConnect; it means the request never reached the handshake.

Resolver Context

The default context a resolver receives is DefaultGraphqlContext, and its shape is per-transport:

  • HTTP — services (the live, request-scoped registry) and requestContext are present; user/tenant are present when the request carried a principal or tenant.
  • WebSocket — services and connection (a GraphqlConnectionInfo) are present; requestContext is absent, because the runtime closes the upgrade request once the handshake response is returned — a synthesized one would be dead by the time a resolver runs. The upgrade request’s headers and query live on connection.headers and connection.query; identity set by your onConnect hook (info.data.set('user', …)) surfaces as ctx.user.

requestContext is typed optional for exactly this reason: the compiler enforces the caveat.

The WebSocket route opts out of websocket-plugin’s shared heartbeat sweep, because that sweeper sends a raw text frame a graphql-transport-ws client must answer by closing 4400. Liveness on this route is the protocol’s own ping/pong via subscriptions.websocket.heartbeatMs.

The SSE transport follows the graphql-sse protocol for distinct-connections mode: a GraphQL request error is delivered inside the accepted event stream as a next event, not as a 400, because a 400 makes the user agent fail the connection and leaves native EventSource with nothing to read.

Batching and Automatic Persisted Queries

Both are opt-in:

GraphqlPlugin({
  typeDefs,
  resolvers,
  maxBatchSize: 25, // 0 (the default) keeps refusing an array body with 400
  apq: { ttlSeconds: 300 }, // omitted disables APQ entirely
});

APQ stores documents under an apq: prefix in CAPABILITIES.CACHE when a cache store is registered, and in a bounded in-process LRU otherwise. A submitted hash is verified against the submitted document before it is persisted, so a shared store cannot be poisoned with a document under another client’s hash. Hashing uses IRuntimeServices.subtle, so apq requires CAPABILITIES.RUNTIME.

Security

  • Query depth limiting is enabled by default (max 10 levels). maxDepth bounds NESTING only — a document can be two levels deep and still ask for a hundred thousand fields, because every alias is a separate field. maxNodes bounds that dimension by counting the fields a document resolves; it is off by default, so set it from the largest query your own clients issue. Neither option stands in for the other
  • Internal errors are masked by default — including errors raised inside a live subscription, which are masked by the same code path the HTTP transport uses
  • APQ verifies a submitted hash against the submitted document before persisting it
  • Introspection is enabled by default (disable in production if needed)
  • GraphiQL is enabled by default (disable in production with graphiql: false)
  • The request body itself is bounded by RuntimePlugin({ maxBodyBytes }), not by this plugin — a document large enough to matter is refused before it is parsed, so maxNodes never sees it. Both HTTP transports report that refusal as 413 with extensions.code: 'REQUEST_BODY_TOO_LARGE', distinct from the 400 INVALID_JSON a body that was read and was not JSON still gets: a client told its JSON is bad re-sends the same oversized document, where one told its body is too large does not

License

MIT

Exports

ExportKind
adaptGraphqlModulefunction
createDepthLimitRulefunction
encodeSseCommentfunction
encodeSseCompletefunction
encodeSseEventfunction
extractPersistedQueryfunction
graphiqlHtmlfunction
GraphqlPluginfunction
loadGraphqlModulefunction
persistedQueryHashfunction
ApqResolverclass
GraphqlRuntimeLoadErrorclass
GraphqlSchemaErrorclass
GraphqlServiceclass
GRAPHQL_TRANSPORT_WSconst
AnySubscriptionResolverinterface
DefaultGraphqlContextinterface
GraphqlApqOptionsinterface
GraphqlCodeFirstOptionsinterface
GraphqlConnectionInfointerface
GraphqlContextInputinterface
GraphqlExecutionResultinterface
GraphqlFormattedErrorinterface
GraphqlModuleLikeinterface
GraphqlOperationContextinterface
GraphqlRequestParamsinterface
GraphqlScalarResolverinterface
GraphqlScalarTypeLikeinterface
GraphqlSchemaFirstOptionsinterface
GraphqlSchemaLikeinterface
GraphqlSseTransportOptionsinterface
GraphqlSubscriptionsOptionsinterface
GraphqlWsTransportOptionsinterface
IApqResolverinterface
IGraphqlServiceinterface
SubscriptionResolverinterface
AnyFieldResolvertype
ApqResolveResulttype
FieldResolvertype
GraphqlPluginOptionstype
GraphqlSubscriptionOutcometype
ResolverMaptype
TypeResolverMaptype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

GraphQL (@setu-ts/graphql-plugin)

Schema-first and code-first GraphQL support over the kernel router.

Overview

GraphQL plugin providing schema construction, execution, and HTTP transport. Supports both SDL-based schema definition with resolver maps and pre-built schemas. Includes media-type negotiation for application/graphql-response+json, error masking, query-depth limiting, and optional GraphiQL UI.

Capability Token

  • CAPABILITIES.GRAPHQL ('graphql') — the capability token under which GraphqlPlugin registers the IGraphqlService.

Usage

Pass the plugin to createApplication({ plugins: [...] }) — there is no new Application() / app.use() API:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    // Schema-first
    GraphqlPlugin({
      typeDefs: `
        type Query {
          hello(name: String!): String
        }
      `,
      resolvers: {
        Query: {
          hello: (_, { name }) => `Hello, ${name}!`,
        },
      },
    }),
  ],
});

await app.start({ port: 3000 });

// Resolve the service AFTER start() — plugins register during start().
const graphql = app.services.get<IGraphqlService>(CAPABILITIES.GRAPHQL);

Code-first differs only in passing a pre-built schema instead of typeDefs + resolvers:

import { buildSchema } from 'graphql';
const schema = buildSchema(`type Query { hello: String }`);

GraphqlPlugin({ schema });

Options

OptionTypeDefaultDescription
typeDefsstring-SDL schema definition (schema-first mode)
resolversResolverMap-Resolver map (schema-first mode)
schemaGraphqlSchemaLike-Pre-built schema (code-first mode)
pathstring/graphqlEndpoint path
graphiqlbooleantrueEnable GraphiQL UI
introspectionbooleantrueEnable schema introspection
maxDepthnumber10Maximum query depth (0 to disable)
validationRulesunknown[]omittedExtra rules, appended after the built-ins, assembled once at registration
maskInternalErrorsbooleantrueMask internal server errors
formatError(error: unknown) => unknownomittedCustom error formatter applied after masking
documentCacheSizenumber1000Max cached documents (0 to disable)
buildContext(input: GraphqlContextInput) => unknownomittedCustom context builder
rootValueunknownomittedRoot value for resolvers
graphqlModuleGraphqlModuleLikeomittedInjected graphql module (for testing or code-first scenarios)
subscriptionsGraphqlSubscriptionsOptionsomittedSubscription transports. Omitted registers no transport route at all.
apqGraphqlApqOptionsomittedAutomatic Persisted Queries. Omitted disables APQ; requires CAPABILITIES.RUNTIME
maxBatchSizenumber00 disables batching and an array body is still refused with 400

subscriptions

OptionTypeDefaultPurpose
websocketGraphqlWsTransportOptions | falseenabledfalse disables the WebSocket transport
websocket.pathstring`${path}/ws`WebSocket endpoint
websocket.connectionInitWaitMsnumber3000Close 4408 when no connection_init arrives
websocket.heartbeatMsnumber0Protocol ping interval; 0 disables
websocket.onConnect(info) => false | voidomittedReturning false closes 4403: Forbidden
sseGraphqlSseTransportOptions | falseenabledfalse disables the SSE transport
sse.pathstring`${path}/stream`SSE endpoint
sse.heartbeatMsnumber0:keep-alive comment interval; 0 disables

apq

OptionTypeDefaultPurpose
ttlSecondsnumber300TTL for entries in a registered ICacheStore
maxEntriesnumber1000Bound on the in-process LRU used when no cache store is registered

Exports

ExportKindPurpose
GraphqlPluginfunctionPlugin factory — registers IGraphqlService under CAPABILITIES.GRAPHQL
GraphqlServiceclassThe IGraphqlService implementation; exported for testing
adaptGraphqlModulefunctionStructural adaptation of graphql module into internal runtime port
graphiqlHtmlfunctionGenerates GraphiQL UI HTML page
createDepthLimitRulefunctionCreates a validation rule for query depth limiting
GraphqlSchemaErrorclassThrown when schema construction or resolver attachment fails
GraphqlRuntimeLoadErrorclassThrown when graphql runtime cannot be loaded
loadGraphqlModulefunctionLoads graphql through a real dynamic import
GraphqlPluginOptionstypeThe factory parameter shape (union of the two arms)
GraphqlSchemaFirstOptionstypeThe schema-first arm of that union
GraphqlCodeFirstOptionstypeThe code-first arm of that union
ResolverMaptypeResolver map for schema-first mode
TypeResolverMaptypeThe resolver entries for one object or interface type
FieldResolvertypeField resolver function type
AnyFieldResolvertypeThe bivariant entry type a TypeResolverMap field holds — accepts a narrowly annotated resolver AND contextually types an unannotated one
AnySubscriptionResolverinterfaceThe bivariant entry type a TypeResolverMap subscription field holds — same rule as AnyFieldResolver, for { subscribe, resolve? } entries
SubscriptionResolvertypeA subscription field’s { subscribe, resolve? } pair
GraphqlScalarResolvertypeCustom scalar serialize/parseValue/parseLiteral methods
GraphqlSubscriptionsOptionstypeThe subscriptions option (WebSocket and SSE arms)
GraphqlWsTransportOptionstypeWebSocket transport options, including onConnect
GraphqlSseTransportOptionstypeSSE transport options
GraphqlApqOptionstypeAutomatic Persisted Queries options
ApqResolverclassVerifies and resolves persisted-query hashes
IApqResolvertypeThe port the transports consume; implemented by ApqResolver
ApqResolveResulttypeThe resolved query, or a refusal carrying its code
extractPersistedQueryfunctionReads { version, sha256Hash } from a request’s extensions
persistedQueryHashfunctionSHA-256 hex of a query, over an injected SubtleCrypto
encodeSseEventfunctionEncodes a next SSE frame
encodeSseCompletefunctionEncodes the complete SSE frame, empty data: field included
encodeSseCommentfunctionEncodes a :keep-alive comment frame
GRAPHQL_TRANSPORT_WSconstThe 'graphql-transport-ws' subprotocol identifier
GraphqlScalarTypeLiketypeStructural constraint for a custom scalar type
GraphqlSchemaLiketypeStructural constraint for pre-built schemas
GraphqlModuleLiketypeStructural constraint for injected graphql modules
DefaultGraphqlContexttypeDefault context shape passed to resolvers
GraphqlContextInputtypeInput type for custom context builder

GraphqlRuntime and the structural graphql facades are not exported. They are an internal port.

Notes

  • Two schema construction arms. Schema-first (typeDefs + resolvers) and code-first (schema) are mutually exclusive; supplying both is a compile error.
  • Resolver context. Without buildContext, resolvers receive a DefaultGraphqlContext whose shape is per-transport. Over HTTP: { services, requestContext, user?, tenant? } — services is the live IServiceRegistry, so a resolver reaches any other capability through it, and user/tenant are whatever the auth and multi-tenancy middleware published on the request. Over WebSocket: { services, connection } — requestContext is absent (not undefined-valued): the runtime closes the upgrade request once the handshake response is returned, so a synthesized one would be dead by the time a resolver runs; that is why the member is typed optional. The upgrade request’s headers and query live on GraphqlConnectionInfo.headers/.query, and identity set by an onConnect hook via info.data.set('user', …) surfaces as ctx.user. Supplying buildContext replaces that object wholesale.
  • Media-type negotiation and the status watershed. Responds with application/graphql-response+json when the client requests it, otherwise application/json — including for failures raised before execution, so a client is never handed a media type it did not ask for. Under graphql-response, a request error (parse, validation, operation resolution) is 400, a subscription over HTTP is 400, and a mutation over GET is 405. Under application/json, every request the endpoint processed as GraphQL answers 200 with the error in the body, because a client predating the newer media type reads a non-200 as a network failure and never reads the errors array. Exactly three cases keep their status under application/json — an unsupported request content type (415), a malformed JSON body (400), and a mutation over GET (405) — because none of them is a GraphQL result. An APQ refusal follows the watershed too: under application/json it answers 200 with PersistedQueryNotFound in the body (it is exactly the error a client must read and retry), while under graphql-response it carries the resolver’s own status. Batching remains refused before per-element resolution, so an APQ miss inside a batch under graphql-response surfaces as 400 BATCHING_NOT_SUPPORTED. The status is decided from the outcome alone and never from the response body, so a formatError hook cannot change it.
  • Two resolver authoring styles, both supported. FieldResolver<TSource, TContext, TArgs> is generic with unknown defaults, so a resolver may be annotated narrowly (FieldResolver<IssueRow, DefaultGraphqlContext, { id: string }>) and still assign to a ResolverMap. A resolver written unannotated — (source, args) => …, the ordinary schema-first shape — takes its parameter types contextually from AnyFieldResolver, so args is Record<string, unknown> rather than never. The map entry is bivariant for exactly this reason: a non-bivariant entry can serve one style or the other, never both. Subscription entries follow the same rule through AnySubscriptionResolver, so a typed { subscribe, resolve: (payload: Book) => … } assigns too.
  • An executed operation is always 200. A field error that nulls data is not a request error, so it does not become a 400 even under graphql-response.
  • 405 carries Allow: POST. A mutation sent over GET is refused with METHOD_NOT_ALLOWED and the endpoint advertises the verb that would work.
  • Ambiguous operations. A document whose operation cannot be resolved — several operations with no operationName, or a name absent from the document — is a 400 with code OPERATION_RESOLUTION_FAILED, decided after parse and before validation.
  • Error masking. Internal errors (resolvers throwing without a code in extensions) are masked by default. The original is logged through the plugin context’s logger when one is registered, so masking never silently discards it.
  • Query depth limiting. Defaults to 10 levels; set to 0 to disable.
  • Document caching. Parse and validation results are cached together, keyed on the raw query string, and the operation guard reads the cached AST — so a repeated query is neither re-parsed nor re-validated. documentCacheSize: 0 disables the cache.
  • GraphiQL UI. Enabled by default, served on GET /graphql when the request has no query parameter and accepts text/html.
  • Platform notes. The graphql package reads process.env.NODE_ENV at import time. Deno requires --allow-env; Cloudflare Workers requires nodejs_compat.
  • Subscriptions are opt-in and never ride the HTTP endpoint. Supply subscriptions to register the transports; omit it and no transport route exists. POST/GET /graphql continues to refuse a subscription operation with 400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP, unchanged — a subscription is served only on `${path}/ws` and `${path}/stream`.
  • Declaring a subscription (schema-first). A Subscription field’s resolver entry is a SubscriptionResolver — { subscribe, resolve? } — not a bare function. subscribe returns the async iterable the field streams from and is attached to the schema field’s own subscribe slot; graphql reads the event source from there and nowhere else.
  • Both transports require CAPABILITIES.RUNTIME. They take their timers (the connection-init timeout, the protocol ping, the SSE keep-alive) from IRuntimeServices rather than global timers, so configuring subscriptions without a registered runtime throws at registration naming the requirement — rather than failing at the first connection.
  • The WebSocket transport is optional and self-limiting. It registers only when CAPABILITIES.WEBSOCKET is present AND IWebSocketService.available is true; otherwise the plugin logs a notice and everything else carries on. The route claims heartbeat: false on WebSocketRouteOptions, because websocket-plugin’s shared sweeper sends a raw text frame that a conformant graphql-transport-ws client must answer by closing 4400. Liveness on that route is the protocol’s own ping/pong, configured by subscriptions.websocket.heartbeatMs.
  • Authenticating a subscription. subscriptions.websocket.onConnect runs on connection_init before the ack, receives the GraphqlConnectionInfo (including connectionParams — the protocol’s auth channel — plus the upgrade headers and query), and closes the socket with 4403: Forbidden when it returns false. Writing to conn.data there sets the user/tenant the default resolver context reads back. With no hook configured the socket is accepted. onConnect and a pipeline guard on the socket path are mutually exclusive: connection_init is a message on an already-open socket, and since M70a a guard runs on the upgrade itself, so a guard covering the /graphql prefix answers GET /graphql/ws with 401 and onConnect never runs. Either scope the guard to the HTTP endpoint and authenticate in onConnect, or authenticate on the upgrade with a credential a browser can attach there — a cookie, through auth-plugin’s session strategy. See the package README’s “onConnect and a route guard cannot both authenticate the socket”.
  • Resolver context over a socket. A WebSocket carries no IRequestContext, so IGraphqlService.subscribe takes a GraphqlOperationContext instead. On that path services is the plugin-level IServiceRegistry, so a subscription resolver reaches every capability an HTTP resolver can.
  • Errors inside a live subscription are masked. maskInternalErrors applies identically on every transport: masking happens once, in the service, and the transports put the already-masked payload on the wire.
  • SSE follows the graphql-sse protocol, not the HTTP watershed. In distinct-connections mode a GraphQL request error (parse, validation, operation resolution) is delivered INSIDE the accepted text/event-stream as a next event followed by complete — not as a 400, which would make the user agent fail the connection and give native EventSource nothing to read. A transport failure that happens before any GraphQL request exists (unsupported content type, unparseable body, a missing query parameter) is still an ordinary buffered HTTP error. A persisted-query miss counts as a request error and is therefore delivered in-stream too. The complete frame always carries an empty data: field, without which EventSource never fires the listener.
  • Automatic Persisted Queries verify the hash. A request carrying both a query and a hash is persisted only when sha256(query) matches the submitted sha256Hash; a mismatch answers PERSISTED_QUERY_HASH_MISMATCH. Without that check any client could store a document under a hash another client later executes — a cache-poisoning primitive, and worst when the store is a shared Redis. A hash-only request that misses answers PersistedQueryNotFound / PERSISTED_QUERY_NOT_FOUND, the standard retry signal. Entries live under the apq: namespace in CAPABILITIES.CACHE when one is registered, and in a bounded in-process LRU otherwise. Hashing uses IRuntimeServices.subtle, so configuring apq without CAPABILITIES.RUNTIME throws at registration.
  • Batching is opt-in. maxBatchSize: 0 (the default) keeps refusing an array body with 400. Above 0, an array body executes its elements concurrently and answers a JSON array in request order; over the limit is 400 BATCH_TOO_LARGE. A batch always answers application/json — the application/graphql-response+json media type describes a single result and cannot express an array, so an array body from a client negotiating it is refused with 400 BATCHING_NOT_SUPPORTED.
  • Custom scalars. In the schema-first arm a resolver-map entry for a scalar type supplies any subset of serialize, parseValue, and parseLiteral; omitted members keep graphql’s identity default.

Agent card · TypeDoc API reference