Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 43
docs: cover custom API handlers and server adapters for v3#508
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base:main
Are you sure you want to change the base?
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> { | ||
| prefix?: string; | ||
| getClient(request: IncomingMessage, response: ServerResponse): ClientContract<Schema> | Promise<ClientContract<Schema>>; | ||
| } | ||
| export function createNodeAdapter<Schema extends SchemaDef>( | ||
| options: NodeAdapterOptions<Schema>, | ||
| ): (request: IncomingMessage, response: ServerResponse) => Promise<void> { | ||
| 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<Schema> | 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<Schema> = { | ||
| 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<Buffer> = []; | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Schema> { | ||
| constructor(options: RestApiHandlerOptions<Schema>) { | ||
| // RestApiHandlerOptions is generic and must be parameterized with your schema type | ||
| super(options); | ||
| } | ||
| protected override buildFilter(type: string, query: Record<string, string | string[]> | 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<string, unknown>) } // ensure filter is a plain object before spreading | ||
| : {}; | ||
mwillbanks marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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<Schema> { | ||
| constructor(options: RPCApiHandlerOptions<Schema>) { | ||
| 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<Schema> { | ||
| constructor(private readonly logLevel: 'info' | 'debug' = 'info') {} | ||
| get schema(): Schema { | ||
| return schema; | ||
| } | ||
| get log() { | ||
| return undefined; | ||
| } | ||
| async handleRequest({ method }: RequestContext<Schema>): Promise<Response> { | ||
| 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). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
mwillbanks marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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<typeof schema> { | ||
| protected override buildFilter(type: string, query: Record<string, string | string[]> | 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<string, unknown>) } | ||
| : {}; | ||
| 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). | ||
| ::: | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.