|
| 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). |
0 commit comments