Skip to content

Commit a873b9a

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 a873b9a

File tree

5 files changed

+384
-1
lines changed

5 files changed

+384
-1
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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): ClientContract<Schema> | Promise<ClientContract<Schema>>;
44+
}
45+
46+
export function createNodeAdapter<Schema extends SchemaDef>(
47+
options: NodeAdapterOptions<Schema>,
48+
): (request: IncomingMessage, response: ServerResponse) => Promise<void> {
49+
const prefix = options.prefix ?? '/api';
50+
51+
return async (request, response) => {
52+
if (!request.url || !request.method || !request.url.startsWith(prefix)) {
53+
response.statusCode = 404;
54+
response.end();
55+
return;
56+
}
57+
58+
let client: ClientContract<Schema> | undefined;
59+
try {
60+
client = await options.getClient(request, response);
61+
} catch (err) {
62+
logInternalError(options.apiHandler.log, err);
63+
}
64+
65+
if (!client) {
66+
response.statusCode = 500;
67+
response.setHeader('content-type', 'application/json');
68+
response.end(JSON.stringify({ message: 'Unable to resolve ZenStack client' }));
69+
return;
70+
}
71+
72+
const url = new URL(request.url, 'http://localhost');
73+
const query = Object.fromEntries(url.searchParams);
74+
const requestBody = await readJson(request);
75+
76+
const context: RequestContext<Schema> = {
77+
method: request.method,
78+
path: url.pathname.slice(prefix.length) || '/',
79+
query,
80+
requestBody,
81+
client,
82+
};
83+
84+
try {
85+
const handlerResponse = await options.apiHandler.handleRequest(context);
86+
response.statusCode = handlerResponse.status;
87+
response.setHeader('content-type', 'application/json');
88+
response.end(JSON.stringify(handlerResponse.body));
89+
} catch (err) {
90+
logInternalError(options.apiHandler.log, err);
91+
response.statusCode = 500;
92+
response.setHeader('content-type', 'application/json');
93+
response.end(JSON.stringify({ message: 'An internal server error occurred' }));
94+
}
95+
};
96+
}
97+
98+
async function readJson(request: IncomingMessage) {
99+
const chunks: Array<Buffer> = [];
100+
for await (const chunk of request) {
101+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
102+
}
103+
if (chunks.length === 0) {
104+
return undefined;
105+
}
106+
107+
const payload = Buffer.concat(chunks).toString('utf8');
108+
return payload ? JSON.parse(payload) : undefined;
109+
}
110+
```
111+
112+
You can plug the adapter into a server just like the packaged adapters:
113+
114+
```ts
115+
import { createServer } from 'http';
116+
import { RestApiHandler } from '@zenstackhq/server/api';
117+
import { schema } from '~/zenstack/schema';
118+
import { createNodeAdapter } from './node-adapter';
119+
120+
const handler = new RestApiHandler({ schema, endpoint: 'https://api.example.com' });
121+
122+
createServer(
123+
createNodeAdapter({
124+
prefix: '/api',
125+
apiHandler: handler,
126+
getClient: (req, res) => /* return a tenant-aware ZenStack client based on req */,
127+
}),
128+
).listen(3000);
129+
```
130+
131+
## Where to go next
132+
133+
- 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.
134+
- 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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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. 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+
// RestApiHandlerOptions is generic and must be parameterized with your schema type
48+
super(options);
49+
}
50+
51+
protected override buildFilter(type: string, query: Record<string, string | string[]> | undefined) {
52+
const base = super.buildFilter(type, query);
53+
if (type !== 'post') {
54+
return base;
55+
}
56+
57+
const existing =
58+
base.filter && typeof base.filter === 'object' && !Array.isArray(base.filter)
59+
? { ...(base.filter as Record<string, unknown>) } // ensure filter is a plain object before spreading
60+
: {};
61+
62+
return {
63+
...base,
64+
filter: {
65+
...existing,
66+
published: true,
67+
},
68+
};
69+
}
70+
}
71+
72+
export const handler = new PublishedOnlyRestHandler({
73+
schema,
74+
endpoint: 'https://api.example.com',
75+
});
76+
```
77+
78+
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:
79+
80+
- `processRequestBody` to accept additional payload metadata;
81+
- `handleGenericError` to hook into your observability pipeline;
82+
- `buildRelationSelect`, `buildSort`, or `includeRelationshipIds` to expose bespoke query features.
83+
84+
For canonical behavior and extension points, see [RESTful API Handler](./rest).
85+
86+
## Extending the RPC handler
87+
88+
`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.
89+
90+
```ts
91+
import { RPCApiHandler, type RPCApiHandlerOptions } from '@zenstackhq/server/api';
92+
import { schema } from '~/zenstack/schema';
93+
94+
type Schema = typeof schema;
95+
96+
class Base64QueryHandler extends RPCApiHandler<Schema> {
97+
constructor(options: RPCApiHandlerOptions<Schema>) {
98+
super(options);
99+
}
100+
101+
protected override unmarshalQ(value: string, meta: string | undefined) {
102+
if (value.startsWith('base64:')) {
103+
const decoded = Buffer.from(value.slice('base64:'.length), 'base64').toString('utf8');
104+
return super.unmarshalQ(decoded, meta);
105+
}
106+
return super.unmarshalQ(value, meta);
107+
}
108+
}
109+
110+
export const handler = new Base64QueryHandler({ schema });
111+
```
112+
113+
The example uses Node's `Buffer` utility to decode the payload; adapt the decoding logic if you target an edge runtime.
114+
115+
Other useful hooks include:
116+
117+
- `processRequestPayload` for enforcing per-request invariants (e.g., injecting tenant IDs);
118+
- `makeBadInputErrorResponse`, `makeGenericErrorResponse`, and `makeORMErrorResponse` for customizing the error shape;
119+
- `isValidModel` if you expose a restricted subset of models to a specific client.
120+
121+
For canonical behavior and extension points, see [RPC API Handler](./rpc).
122+
123+
## Implementing a handler from scratch
124+
125+
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.
126+
127+
```ts
128+
import type { ApiHandler, RequestContext, Response } from '@zenstackhq/server/types';
129+
import { registerCustomSerializers } from '@zenstackhq/server/api';
130+
import { schema } from '~/zenstack/schema';
131+
132+
type Schema = typeof schema;
133+
134+
registerCustomSerializers();
135+
136+
class HealthcheckHandler implements ApiHandler<Schema> {
137+
constructor(private readonly logLevel: 'info' | 'debug' = 'info') {}
138+
139+
get schema(): Schema {
140+
return schema;
141+
}
142+
143+
get log() {
144+
return undefined;
145+
}
146+
147+
async handleRequest({ method }: RequestContext<Schema>): Promise<Response> {
148+
if (method.toUpperCase() !== 'GET') {
149+
return { status: 405, body: { error: 'Only GET is supported' } };
150+
}
151+
return { status: 200, body: { data: { status: 'ok', timestamp: Date.now() } } };
152+
}
153+
}
154+
155+
export const handler = new HealthcheckHandler();
156+
```
157+
158+
## Plugging a custom handler into your app
159+
160+
Custom handlers are consumed exactly like the built-in ones—hand them to any server adapter through the shared `apiHandler` option.
161+
162+
```ts
163+
import { ZenStackMiddleware } from '@zenstackhq/server/express';
164+
import { PublishedOnlyRestHandler } from './handler';
165+
import { getClientFromRequest } from './auth';
166+
167+
app.use(
168+
'/api',
169+
ZenStackMiddleware({
170+
apiHandler: new PublishedOnlyRestHandler({ schema, endpoint: 'https://api.example.com' }),
171+
getClient: getClientFromRequest,
172+
})
173+
);
174+
```
175+
176+
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)