Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions versioned_docs/version-3.x/reference/server-adapters/custom.mdx
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 versioned_docs/version-3.x/service/api-handler/custom.md
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).
44 changes: 43 additions & 1 deletion versioned_docs/version-3.x/service/api-handler/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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).
:::
Loading