-
-
Notifications
You must be signed in to change notification settings - Fork 38
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
Open
mwillbanks
wants to merge
1
commit into
zenstackhq:main
Choose a base branch
from
mwillbanks:feat/server-handler-adapter-custom
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
134 changes: 134 additions & 0 deletions
134
versioned_docs/version-3.x/reference/server-adapters/custom.mdx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
176 changes: 176 additions & 0 deletions
176
versioned_docs/version-3.x/service/api-handler/custom.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| : {}; | ||
|
|
||
| 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). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.