Skip to content

Commit fe59ca9

Browse files
committed
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
1 parent 66ea0da commit fe59ca9

File tree

5 files changed

+385
-1
lines changed

5 files changed

+385
-1
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
title: Custom Server Adapter
3+
description: Wire ZenStack API handlers into frameworks that do not have a built-in adapter yet.
4+
sidebar_position: 20
5+
---
6+
7+
# Custom Server Adapter
8+
9+
## When to build one
10+
11+
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.
12+
13+
## Core contracts
14+
15+
```ts
16+
import { logInternalError, type CommonAdapterOptions } from '@zenstackhq/server/common';
17+
import type { ApiHandler, RequestContext, Response } from '@zenstackhq/server/types';
18+
```
19+
20+
- `CommonAdapterOptions` gives every adapter an `apiHandler` field so it can delegate work to REST, RPC, or a custom handler.
21+
- `logInternalError` mirrors the logging behavior of the official adapters, making it easy to surface unexpected failures.
22+
- `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.
23+
24+
## Implementation outline
25+
26+
1. **Identify the minimal options surface.** Extend `CommonAdapterOptions` with whatever context your framework needs (for example, a `getClient` callback or a URL prefix).
27+
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.
28+
3. **Send the handler response back through the framework.** Serialize `Response.body`, apply the status code, and fall back to `logInternalError` if anything throws.
29+
30+
## Example: minimal Node HTTP adapter
31+
32+
The snippet below wires `IncomingMessage`/`ServerResponse` from Node's `http` module into any ZenStack handler.
33+
34+
```ts
35+
import type { IncomingMessage, ServerResponse } from 'http';
36+
import type { ClientContract } from '@zenstackhq/orm';
37+
import type { SchemaDef } from '@zenstackhq/orm/schema';
38+
import { logInternalError, type CommonAdapterOptions } from '@zenstackhq/server/common';
39+
import type { RequestContext } from '@zenstackhq/server/types';
40+
41+
interface NodeAdapterOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
42+
prefix?: string;
43+
getClient(request: IncomingMessage, response: ServerResponse):
44+
| ClientContract<Schema>
45+
| Promise<ClientContract<Schema>>;
46+
}
47+
48+
export function createNodeAdapter<Schema extends SchemaDef>(
49+
options: NodeAdapterOptions<Schema>,
50+
): (request: IncomingMessage, response: ServerResponse) => Promise<void> {
51+
const prefix = options.prefix ?? '/api';
52+
53+
return async (request, response) => {
54+
if (!request.url || !request.method || !request.url.startsWith(prefix)) {
55+
response.statusCode = 404;
56+
response.end();
57+
return;
58+
}
59+
60+
let client: ClientContract<Schema> | undefined;
61+
try {
62+
client = await options.getClient(request, response);
63+
} catch (err) {
64+
logInternalError(options.apiHandler.log, err);
65+
}
66+
67+
if (!client) {
68+
response.statusCode = 500;
69+
response.setHeader('content-type', 'application/json');
70+
response.end(JSON.stringify({ message: 'Unable to resolve ZenStack client' }));
71+
return;
72+
}
73+
74+
const url = new URL(request.url, 'http://localhost');
75+
const query = Object.fromEntries(url.searchParams);
76+
const requestBody = await readJson(request);
77+
78+
const context: RequestContext<Schema> = {
79+
method: request.method,
80+
path: url.pathname.slice(prefix.length) || '/',
81+
query,
82+
requestBody,
83+
client,
84+
};
85+
86+
try {
87+
const handlerResponse = await options.apiHandler.handleRequest(context);
88+
response.statusCode = handlerResponse.status;
89+
response.setHeader('content-type', 'application/json');
90+
response.end(JSON.stringify(handlerResponse.body));
91+
} catch (err) {
92+
logInternalError(options.apiHandler.log, err);
93+
response.statusCode = 500;
94+
response.setHeader('content-type', 'application/json');
95+
response.end(JSON.stringify({ message: 'An internal server error occurred' }));
96+
}
97+
};
98+
}
99+
100+
async function readJson(request: IncomingMessage) {
101+
const chunks: Array<Buffer> = [];
102+
for await (const chunk of request) {
103+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
104+
}
105+
if (chunks.length === 0) {
106+
return undefined;
107+
}
108+
109+
const payload = Buffer.concat(chunks).toString('utf8');
110+
return payload ? JSON.parse(payload) : undefined;
111+
}
112+
```
113+
114+
You can plug the adapter into a server just like the packaged adapters:
115+
116+
```ts
117+
import { createServer } from 'http';
118+
import { RestApiHandler } from '@zenstackhq/server/api';
119+
import { schema } from '~/zenstack/schema';
120+
import { createNodeAdapter } from './node-adapter';
121+
122+
const handler = new RestApiHandler({ schema, endpoint: 'https://api.example.com' });
123+
124+
createServer(
125+
createNodeAdapter({
126+
prefix: '/api',
127+
apiHandler: handler,
128+
getClient: () => /* return a tenant-aware ZenStack client */,
129+
}),
130+
).listen(3000);
131+
```
132+
133+
## Where to go next
134+
135+
- 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.
136+
- 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.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
---
2+
sidebar_position: 3
3+
sidebar_label: Custom
4+
title: Custom API Handler
5+
description: Extend or implement ZenStack API handlers to match your backend conventions.
6+
---
7+
8+
# Custom API Handler
9+
10+
## Overview
11+
12+
ZenStack ships ready-to-use REST and RPC handlers, but you can tailor their behavior or author brand new handlers without leaving TypeScript. The server package exposes the handler contracts through `@zenstackhq/server/types`, utility helpers from `@zenstackhq/server/api`, all built-in handlers expose their methods as `protected` to allow for extension points. You can:
13+
14+
- override parts of the REST or RPC pipeline (filtering, serialization, validation, error handling, and more);
15+
- wrap the default handlers with extra behavior (multi-tenancy, telemetry, custom logging);
16+
- implement a handler from scratch while still benefiting from ZenStack's schema and serialization helpers.
17+
18+
## Core building blocks
19+
20+
```ts
21+
import {
22+
type ApiHandler,
23+
type RequestContext,
24+
type Response,
25+
type LogConfig,
26+
} from '@zenstackhq/server/types';
27+
import { registerCustomSerializers, getZodErrorMessage, log } from '@zenstackhq/server/api';
28+
```
29+
30+
- `ApiHandler`, `RequestContext`, and `Response` define the framework-agnostic contract used by every server adapter.
31+
- `LogConfig` (and the related `Logger` type) mirrors the handler `log` option so you can surface diagnostics consistently.
32+
- `registerCustomSerializers` installs the Decimal/Bytes superjson codecs that power the built-in handlers—call it once when implementing your own handler.
33+
- `getZodErrorMessage` and `log` help you align error formatting and logging with the defaults.
34+
35+
## Extending the REST handler
36+
37+
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.
38+
39+
```ts
40+
import { RestApiHandler, type RestApiHandlerOptions } from '@zenstackhq/server/api';
41+
import { schema } from '~/zenstack/schema';
42+
43+
type Schema = typeof schema;
44+
45+
class PublishedOnlyRestHandler extends RestApiHandler<Schema> {
46+
constructor(options: RestApiHandlerOptions<Schema>) {
47+
super(options);
48+
}
49+
50+
protected override buildFilter(type: string, query: Record<string, string | string[]> | undefined) {
51+
const base = super.buildFilter(type, query);
52+
if (type !== 'post') {
53+
return base;
54+
}
55+
56+
const existing =
57+
base.filter && typeof base.filter === 'object' && !Array.isArray(base.filter)
58+
? { ...(base.filter as Record<string, unknown>) }
59+
: {};
60+
61+
return {
62+
...base,
63+
filter: {
64+
...existing,
65+
published: true,
66+
},
67+
};
68+
}
69+
}
70+
71+
export const handler = new PublishedOnlyRestHandler({
72+
schema,
73+
endpoint: 'https://api.example.com',
74+
});
75+
```
76+
77+
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:
78+
79+
- `processRequestBody` to accept additional payload metadata;
80+
- `handleGenericError` to hook into your observability pipeline;
81+
- `buildRelationSelect`, `buildSort`, or `includeRelationshipIds` to expose bespoke query features.
82+
83+
Refer back to the [RESTful API Handler](./rest) page for the canonical behavior and extension points.
84+
85+
## Extending the RPC handler
86+
87+
`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.
88+
89+
```ts
90+
import { RPCApiHandler, type RPCApiHandlerOptions } from '@zenstackhq/server/api';
91+
import { schema } from '~/zenstack/schema';
92+
93+
type Schema = typeof schema;
94+
95+
class Base64QueryHandler extends RPCApiHandler<Schema> {
96+
constructor(options: RPCApiHandlerOptions<Schema>) {
97+
super(options);
98+
}
99+
100+
protected override unmarshalQ(value: string, meta: string | undefined) {
101+
if (value.startsWith('base64:')) {
102+
const decoded = Buffer.from(value.slice('base64:'.length), 'base64').toString('utf8');
103+
return super.unmarshalQ(decoded, meta);
104+
}
105+
return super.unmarshalQ(value, meta);
106+
}
107+
}
108+
109+
export const handler = new Base64QueryHandler({ schema });
110+
```
111+
112+
The example uses Node's `Buffer` utility to decode the payload; adapt the decoding logic if you target an edge runtime.
113+
114+
Other useful hooks include:
115+
116+
- `processRequestPayload` for enforcing per-request invariants (for example, injecting tenant IDs);
117+
- `makeBadInputErrorResponse`, `makeGenericErrorResponse`, and `makeORMErrorResponse` for customizing the error shape;
118+
- `isValidModel` if you expose a restricted subset of models to a specific client.
119+
120+
Refer back to the [RPC API Handler](./rpc) page for the canonical behavior and endpoint matrix.
121+
122+
## Implementing a handler from scratch
123+
124+
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.
125+
126+
```ts
127+
import type { ApiHandler, RequestContext, Response } from '@zenstackhq/server/types';
128+
import { registerCustomSerializers } from '@zenstackhq/server/api';
129+
import { schema } from '~/zenstack/schema';
130+
131+
type Schema = typeof schema;
132+
133+
registerCustomSerializers();
134+
135+
class HealthcheckHandler implements ApiHandler<Schema> {
136+
constructor(private readonly logLevel: 'info' | 'debug' = 'info') {}
137+
138+
get schema(): Schema {
139+
return schema;
140+
}
141+
142+
get log() {
143+
return undefined;
144+
}
145+
146+
async handleRequest({ method }: RequestContext<Schema>): Promise<Response> {
147+
if (method.toUpperCase() !== 'GET') {
148+
return { status: 405, body: { error: 'Only GET is supported' } };
149+
}
150+
return { status: 200, body: { data: { status: 'ok', timestamp: Date.now() } } };
151+
}
152+
}
153+
154+
export const handler = new HealthcheckHandler();
155+
```
156+
157+
## Plugging a custom handler into your app
158+
159+
Custom handlers are consumed exactly like the built-in ones—hand them to any server adapter through the shared `apiHandler` option.
160+
161+
```ts
162+
import { ZenStackMiddleware } from '@zenstackhq/server/express';
163+
import { PublishedOnlyRestHandler } from './handler';
164+
import { getClientFromRequest } from './auth';
165+
166+
app.use(
167+
'/api',
168+
ZenStackMiddleware({
169+
apiHandler: new PublishedOnlyRestHandler({ schema, endpoint: 'https://api.example.com' }),
170+
getClient: getClientFromRequest,
171+
})
172+
);
173+
```
174+
175+
For adapter-level customization strategies, head over to [Custom Server Adapter](../../reference/server-adapters/custom).

versioned_docs/version-3.x/service/api-handler/rest.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ The factory function accepts an options object with the following fields:
6363

6464
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).
6565

66-
6766
## Endpoints and Features
6867

6968
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:
965964
]
966965
}
967966
```
967+
968+
969+
## Customizing the handler
970+
971+
`RestApiHandler` exposes its internal helpers as `protected`, making it straightforward to extend the default implementation with project-specific rules.
972+
973+
```ts
974+
import { RestApiHandler } from '@zenstackhq/server/api';
975+
import { schema } from '~/zenstack/schema';
976+
977+
class PublishedOnlyRestHandler extends RestApiHandler<typeof schema> {
978+
protected override buildFilter(type: string, query: Record<string, string | string[]> | undefined) {
979+
const base = super.buildFilter(type, query);
980+
if (type !== 'post') {
981+
return base;
982+
}
983+
984+
const existing =
985+
base.filter && typeof base.filter === 'object' && !Array.isArray(base.filter)
986+
? { ...(base.filter as Record<string, unknown>) }
987+
: {};
988+
989+
return {
990+
...base,
991+
filter: {
992+
...existing,
993+
published: true,
994+
},
995+
};
996+
}
997+
}
998+
999+
export const handler = new PublishedOnlyRestHandler({
1000+
schema,
1001+
endpoint: 'https://api.example.com',
1002+
});
1003+
```
1004+
1005+
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.
1006+
1007+
:::tip
1008+
For additional extension patterns and guidance on writing a handler from scratch, see [Custom API Handler](./custom).
1009+
:::

0 commit comments

Comments
 (0)