From a873b9a2171ba2ef81c962a007f213d755f89cfe Mon Sep 17 00:00:00 2001 From: Mike Willbanks Date: Mon, 17 Nov 2025 15:29:06 -0600 Subject: [PATCH] docs v3: server handler and adapter customization - add custom API handler guide with extension examples - update REST and RPC handler docs with customization guidance - add custom server adapter reference and link from catalog - refresh sidebar entries for the new content --- .../reference/server-adapters/custom.mdx | 134 +++++++++++++ .../version-3.x/service/api-handler/custom.md | 176 ++++++++++++++++++ .../version-3.x/service/api-handler/rest.md | 44 ++++- .../version-3.x/service/api-handler/rpc.md | 30 +++ .../version-3.x/service/server-adapter.md | 1 + 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 versioned_docs/version-3.x/reference/server-adapters/custom.mdx create mode 100644 versioned_docs/version-3.x/service/api-handler/custom.md diff --git a/versioned_docs/version-3.x/reference/server-adapters/custom.mdx b/versioned_docs/version-3.x/reference/server-adapters/custom.mdx new file mode 100644 index 00000000..9f4b5ad3 --- /dev/null +++ b/versioned_docs/version-3.x/reference/server-adapters/custom.mdx @@ -0,0 +1,134 @@ +--- +title: Custom Server Adapter +description: Wire ZenStack API handlers into frameworks that do not have a built-in adapter yet. +sidebar_position: 20 +--- + +# Custom Server Adapter + +## When to build one + +Server adapters translate framework-specific requests into the framework-agnostic contract implemented by ZenStack API handlers. If your runtime is not covered by a [built-in adapter](./next), you can create a lightweight bridge by combining the shared adapter utilities and the generic handler contract. + +## Core contracts + +```ts +import { logInternalError, type CommonAdapterOptions } from '@zenstackhq/server/common'; +import type { ApiHandler, RequestContext, Response } from '@zenstackhq/server/types'; +``` + +- `CommonAdapterOptions` gives every adapter an `apiHandler` field so it can delegate work to REST, RPC, or a custom handler. +- `logInternalError` mirrors the logging behavior of the official adapters, making it easy to surface unexpected failures. +- `ApiHandler`, `RequestContext`, and `Response` describe the shape of the data you must provide to the handler and how to forward the result back to your framework. + +## Implementation outline + +1. **Identify the minimal options surface.** Extend `CommonAdapterOptions` with whatever context your framework needs (for example, a `getClient` callback or a URL prefix). +2. **Map the framework request to `RequestContext`.** Collect the HTTP method, path (excluding any prefix), query parameters, body, and the ZenStack client instance. Move the heavy lifting—policy enforcement, serialization, pagination—to the handler. +3. **Send the handler response back through the framework.** Serialize `Response.body`, apply the status code, and fall back to `logInternalError` if anything throws. + +## Example: minimal Node HTTP adapter + +The snippet below wires `IncomingMessage`/`ServerResponse` from Node's `http` module into any ZenStack handler. + +```ts +import type { IncomingMessage, ServerResponse } from 'http'; +import type { ClientContract } from '@zenstackhq/orm'; +import type { SchemaDef } from '@zenstackhq/orm/schema'; +import { logInternalError, type CommonAdapterOptions } from '@zenstackhq/server/common'; +import type { RequestContext } from '@zenstackhq/server/types'; + +interface NodeAdapterOptions extends CommonAdapterOptions { + prefix?: string; + getClient(request: IncomingMessage, response: ServerResponse): ClientContract | Promise>; +} + +export function createNodeAdapter( + options: NodeAdapterOptions, +): (request: IncomingMessage, response: ServerResponse) => Promise { + const prefix = options.prefix ?? '/api'; + + return async (request, response) => { + if (!request.url || !request.method || !request.url.startsWith(prefix)) { + response.statusCode = 404; + response.end(); + return; + } + + let client: ClientContract | undefined; + try { + client = await options.getClient(request, response); + } catch (err) { + logInternalError(options.apiHandler.log, err); + } + + if (!client) { + response.statusCode = 500; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ message: 'Unable to resolve ZenStack client' })); + return; + } + + const url = new URL(request.url, 'http://localhost'); + const query = Object.fromEntries(url.searchParams); + const requestBody = await readJson(request); + + const context: RequestContext = { + method: request.method, + path: url.pathname.slice(prefix.length) || '/', + query, + requestBody, + client, + }; + + try { + const handlerResponse = await options.apiHandler.handleRequest(context); + response.statusCode = handlerResponse.status; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify(handlerResponse.body)); + } catch (err) { + logInternalError(options.apiHandler.log, err); + response.statusCode = 500; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ message: 'An internal server error occurred' })); + } + }; +} + +async function readJson(request: IncomingMessage) { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + if (chunks.length === 0) { + return undefined; + } + + const payload = Buffer.concat(chunks).toString('utf8'); + return payload ? JSON.parse(payload) : undefined; +} +``` + +You can plug the adapter into a server just like the packaged adapters: + +```ts +import { createServer } from 'http'; +import { RestApiHandler } from '@zenstackhq/server/api'; +import { schema } from '~/zenstack/schema'; +import { createNodeAdapter } from './node-adapter'; + +const handler = new RestApiHandler({ schema, endpoint: 'https://api.example.com' }); + +createServer( + createNodeAdapter({ + prefix: '/api', + apiHandler: handler, + getClient: (req, res) => /* return a tenant-aware ZenStack client based on req */, + }), +).listen(3000); +``` + +## Where to go next + +- Review the implementation of a built-in adapter—such as [Express](./express) or [SvelteKit](./sveltekit)—for inspiration on error handling, streaming bodies, and auth integration. +- Pair a custom adapter with an extended handler from [Custom API Handler](../../service/api-handler/custom) to keep framework and business logic responsibilities cleanly separated. diff --git a/versioned_docs/version-3.x/service/api-handler/custom.md b/versioned_docs/version-3.x/service/api-handler/custom.md new file mode 100644 index 00000000..71637ec1 --- /dev/null +++ b/versioned_docs/version-3.x/service/api-handler/custom.md @@ -0,0 +1,176 @@ +--- +sidebar_position: 3 +sidebar_label: Custom +title: Custom API Handler +description: Extend or implement ZenStack API handlers to match your backend conventions. +--- + +# Custom API Handler + +## Overview + +ZenStack ships ready-to-use REST and RPC handlers, but you can tailor their behavior or author brand-new handlers without leaving TypeScript. All built-in handlers expose their methods as `protected` to allow for extension points. You can: + +- override parts of the REST or RPC pipeline (filtering, serialization, validation, error handling, and more) +- wrap the default handlers with extra behavior (multi-tenancy, telemetry, custom logging) +- implement a handler from scratch while still benefiting from ZenStack's schema and serialization helpers + +## Core building blocks + +```ts +import { + type ApiHandler, + type RequestContext, + type Response, + type LogConfig, +} from '@zenstackhq/server/types'; +import { registerCustomSerializers, getZodErrorMessage, log } from '@zenstackhq/server/api'; +``` + +- `ApiHandler`, `RequestContext`, and `Response` define the framework-agnostic contract used by every server adapter. +- `LogConfig` (and the related `Logger` type) mirrors the handler `log` option so you can surface diagnostics consistently. +- `registerCustomSerializers` installs the Decimal/Bytes superjson codecs that power the built-in handlers—call it once when implementing your own handler. +- `getZodErrorMessage` and `log` help you align error formatting and logging with the defaults. + +## Extending the REST handler + +The REST handler exposes its internals (for example `buildFilter`, `processRequestBody`, `handleGenericError`, and serializer helpers) as `protected`, so subclasses can tweak individual steps without re-implementing the whole pipeline. + +```ts +import { RestApiHandler, type RestApiHandlerOptions } from '@zenstackhq/server/api'; +import { schema } from '~/zenstack/schema'; + +type Schema = typeof schema; + +class PublishedOnlyRestHandler extends RestApiHandler { + constructor(options: RestApiHandlerOptions) { + // RestApiHandlerOptions is generic and must be parameterized with your schema type + super(options); + } + + protected override buildFilter(type: string, query: Record | undefined) { + const base = super.buildFilter(type, query); + if (type !== 'post') { + return base; + } + + const existing = + base.filter && typeof base.filter === 'object' && !Array.isArray(base.filter) + ? { ...(base.filter as Record) } // ensure filter is a plain object before spreading + : {}; + + return { + ...base, + filter: { + ...existing, + published: true, + }, + }; + } +} + +export const handler = new PublishedOnlyRestHandler({ + schema, + endpoint: 'https://api.example.com', +}); +``` + +The override inserts a default `published` filter for the `post` collection while delegating everything else to the base class. You can apply the same pattern to other extension points, such as: + +- `processRequestBody` to accept additional payload metadata; +- `handleGenericError` to hook into your observability pipeline; +- `buildRelationSelect`, `buildSort`, or `includeRelationshipIds` to expose bespoke query features. + +For canonical behavior and extension points, see [RESTful API Handler](./rest). + +## Extending the RPC handler + +`RPCApiHandler` exposes similar `protected` hooks. Overriding `unmarshalQ` lets you accept alternative encodings for the `q` parameter, while still benefiting from the built-in JSON/SuperJSON handling. + +```ts +import { RPCApiHandler, type RPCApiHandlerOptions } from '@zenstackhq/server/api'; +import { schema } from '~/zenstack/schema'; + +type Schema = typeof schema; + +class Base64QueryHandler extends RPCApiHandler { + constructor(options: RPCApiHandlerOptions) { + super(options); + } + + protected override unmarshalQ(value: string, meta: string | undefined) { + if (value.startsWith('base64:')) { + const decoded = Buffer.from(value.slice('base64:'.length), 'base64').toString('utf8'); + return super.unmarshalQ(decoded, meta); + } + return super.unmarshalQ(value, meta); + } +} + +export const handler = new Base64QueryHandler({ schema }); +``` + +The example uses Node's `Buffer` utility to decode the payload; adapt the decoding logic if you target an edge runtime. + +Other useful hooks include: + +- `processRequestPayload` for enforcing per-request invariants (e.g., injecting tenant IDs); +- `makeBadInputErrorResponse`, `makeGenericErrorResponse`, and `makeORMErrorResponse` for customizing the error shape; +- `isValidModel` if you expose a restricted subset of models to a specific client. + +For canonical behavior and extension points, see [RPC API Handler](./rpc). + +## Implementing a handler from scratch + +When the built-in handlers are not a fit, implement the `ApiHandler` interface directly. Remember to call `registerCustomSerializers()` once so your handler understands Decimal and Bytes payloads the same way the rest of the stack does. + +```ts +import type { ApiHandler, RequestContext, Response } from '@zenstackhq/server/types'; +import { registerCustomSerializers } from '@zenstackhq/server/api'; +import { schema } from '~/zenstack/schema'; + +type Schema = typeof schema; + +registerCustomSerializers(); + +class HealthcheckHandler implements ApiHandler { + constructor(private readonly logLevel: 'info' | 'debug' = 'info') {} + + get schema(): Schema { + return schema; + } + + get log() { + return undefined; + } + + async handleRequest({ method }: RequestContext): Promise { + if (method.toUpperCase() !== 'GET') { + return { status: 405, body: { error: 'Only GET is supported' } }; + } + return { status: 200, body: { data: { status: 'ok', timestamp: Date.now() } } }; + } +} + +export const handler = new HealthcheckHandler(); +``` + +## Plugging a custom handler into your app + +Custom handlers are consumed exactly like the built-in ones—hand them to any server adapter through the shared `apiHandler` option. + +```ts +import { ZenStackMiddleware } from '@zenstackhq/server/express'; +import { PublishedOnlyRestHandler } from './handler'; +import { getClientFromRequest } from './auth'; + +app.use( + '/api', + ZenStackMiddleware({ + apiHandler: new PublishedOnlyRestHandler({ schema, endpoint: 'https://api.example.com' }), + getClient: getClientFromRequest, + }) +); +``` + +For adapter-level customization strategies, head over to [Custom Server Adapter](../../reference/server-adapters/custom). diff --git a/versioned_docs/version-3.x/service/api-handler/rest.md b/versioned_docs/version-3.x/service/api-handler/rest.md index a58ab2a1..ac01457d 100644 --- a/versioned_docs/version-3.x/service/api-handler/rest.md +++ b/versioned_docs/version-3.x/service/api-handler/rest.md @@ -63,7 +63,6 @@ The factory function accepts an options object with the following fields: Currently it is not possible to use custom index names. This also works for compound unique constraints just like for [compound IDs](#compound-id-fields). - ## Endpoints and Features The RESTful API handler conforms to the the [JSON:API](https://jsonapi.org/format/) v1.1 specification for its URL design and input/output format. The following sections list the endpoints and features are implemented. The examples refer to the following schema modeling a blogging app: @@ -965,3 +964,46 @@ An error response is an object containing the following fields: ] } ``` + + +## Customizing the handler + +`RestApiHandler` exposes its internal helpers as `protected`, making it straightforward to extend the default implementation with project-specific rules. + +```ts +import { RestApiHandler } from '@zenstackhq/server/api'; +import { schema } from '~/zenstack/schema'; + +class PublishedOnlyRestHandler extends RestApiHandler { + protected override buildFilter(type: string, query: Record | undefined) { + const base = super.buildFilter(type, query); + if (type !== 'post') { + return base; + } + + const existing = + base.filter && typeof base.filter === 'object' && !Array.isArray(base.filter) + ? { ...(base.filter as Record) } + : {}; + + return { + ...base, + filter: { + ...existing, + published: true, + }, + }; + } +} + +export const handler = new PublishedOnlyRestHandler({ + schema, + endpoint: 'https://api.example.com', +}); +``` + +The example enforces a default filter for the `post` model while delegating all other behavior (query parsing, serialization, pagination, etc.) to the base class. Similar overrides are available for error handling (`handleGenericError`), request payload processing (`processRequestBody`), relationship serialization (`buildRelationSelect`), and more. + +:::tip +For additional extension patterns and guidance on writing a handler from scratch, see [Custom API Handler](./custom). +::: diff --git a/versioned_docs/version-3.x/service/api-handler/rpc.md b/versioned_docs/version-3.x/service/api-handler/rpc.md index b0e2d98d..65003456 100644 --- a/versioned_docs/version-3.x/service/api-handler/rpc.md +++ b/versioned_docs/version-3.x/service/api-handler/rpc.md @@ -234,3 +234,33 @@ When an error occurs, the response body will have the following shape. See [ORM } } ``` + + +## Customizing the handler + +`RPCApiHandler` exposes several `protected` hooks that let you adapt query parsing, validation, and error formatting without re-implementing the RPC contract. + +```ts +import { RPCApiHandler } from '@zenstackhq/server/api'; +import { schema } from '~/zenstack/schema'; + +class Base64QueryHandler extends RPCApiHandler { + protected override unmarshalQ(value: string, meta: string | undefined) { + if (value.startsWith('base64:')) { + const decoded = Buffer.from(value.slice('base64:'.length), 'base64').toString('utf8'); + return super.unmarshalQ(decoded, meta); + } + return super.unmarshalQ(value, meta); + } +} + +export const handler = new Base64QueryHandler({ schema }); +``` + +This override accepts `q=base64:...` query strings (helpful when URLs must stay ASCII-safe) and then defers to the base implementation for SuperJSON deserialization. You can also override `processRequestPayload` to enforce request invariants or adjust the error helpers (`makeBadInputErrorResponse`, `makeORMErrorResponse`) to match your API surface. + +The example relies on Node's `Buffer`; use an alternative decoder if you run in an edge or browser runtime. + +:::tip +See [Custom API Handler](./custom) for more advanced extension patterns and guidance on wiring a handcrafted handler to a server adapter. +::: \ No newline at end of file diff --git a/versioned_docs/version-3.x/service/server-adapter.md b/versioned_docs/version-3.x/service/server-adapter.md index 2fd84e9a..9209cb4d 100644 --- a/versioned_docs/version-3.x/service/server-adapter.md +++ b/versioned_docs/version-3.x/service/server-adapter.md @@ -47,3 +47,4 @@ ZenStack currently maintains the following server adapters. New ones will be add - [Fastify](../reference/server-adapters/fastify) - [Hono](../reference/server-adapters/hono) - [Elysia](../reference/server-adapters/elysia) +- [Custom](../reference/server-adapters/custom)