One Zod schema → seven REST endpoints + OpenAPI + ETag + Link pagination + idempotency + locks + soft delete. Zero CRUD boilerplate. Pure ESM. TypeScript-first. Built on Fastify.
npm install zod-op zod fastify
# or
yarn add zod-op zod fastifyimport { z } from 'zod';
import { zodop, Resource, MemoryStorePlugin, SwaggerPlugin } from 'zod-op';
const Todo = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
done: z.boolean().default(false),
createdAt: z.date(),
});
const { listen } = await zodop({
title: 'Todo API',
plugins: [new SwaggerPlugin()],
resources: [
new Resource({
name: 'todo',
schema: Todo,
store: new MemoryStorePlugin(),
sortDefault: '-createdAt',
}),
],
});
await listen({ port: 3000 });
// → POST/GET/PATCH/PUT/DELETE /todos[/:id], POST /todos/batch,
// GET /openapi.json, GET /docsThat's it. The seven CRUD endpoints, the OpenAPI 3.1 spec, the Swagger UI, the validated query string with sort and filter allowlists, ETag-based optimistic concurrency, RFC 5988 Link headers on lists — all from the schema above.
ZodOP rejects misconfiguration at compile time — the schema is the source of truth.
| When you write… | …you get back |
|---|---|
softDelete: true on a schema without deletedAt |
TS error at the call site |
sortableFields: ['titel'] (typo) against a schema with title |
TS error at the call site |
A schema that omits id |
TS error pointing at schema: with the rule it broke |
endpoints: { create: { disabled: true }, bulk: { disabled: false } } |
Construction-time throw — bulk is built on create |
?filter[priority]=banana on a z.number() field |
400 with the field name |
If-Match: "stale-etag" on a PATCH |
412 precondition_failed |
| Two concurrent PATCHes on the same id | Serialised through the lock plugin |
A POST retried with the same Idempotency-Key |
The original response, replayed verbatim with Idempotent-Replayed: true |
Plugin-composed REST APIs from Zod schemas. The schema is the source
of truth — configuration that's inconsistent with the schema is
rejected at compile time by TypeScript, and everything else at
construction time by Zod refinements. No duplicated if/else guards,
no "works for you but throws in prod" surprises.
const Post = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
deletedAt: z.date().nullable().default(null),
});
new Resource({
name: 'post',
schema: Post,
store,
softDelete: true, // ✅ OK — the schema declares deletedAt
});
const Comment = z.object({
id: z.string().uuid(),
body: z.string(),
});
new Resource({
name: 'comment',
schema: Comment,
store,
softDelete: true,
// ^^^^^^^^^^^^^^ ❌ TS2322 Type 'true' is not assignable to type 'false'.
});| rule | how |
|---|---|
Resource schema must declare an id field |
conditional type resolves the arg to a descriptive string literal error when id is missing |
softDelete: true only valid when schema declares deletedAt |
conditional type narrows to false | undefined otherwise |
Hook signatures must match z.infer<TSchema> |
generic flow through CrudHooks<z.infer<TSchema>> |
Endpoint overrides key in endpoints must be a real CRUD op |
Partial<Record<CrudOperation, …>> |
Plugin-instance fields (store, locks, redis) must be correct instances |
z.instanceof(StorePlugin) etc. on the plugin's configSchema |
| Plugin placement scope | static scopes is checked against the scope it's placed in |
Every plugin owns a static configSchema. The constructor parses the
caller's config through it, so defaults are applied and invalid inputs
throw before any route mounts. A few examples:
| plugin | refined by |
|---|---|
Resource options |
name / plural are slug-safe (/^[a-z][a-z0-9_-]*$/i), bulkMax ∈ [1, 10_000], tags[] non-empty, description ≤ 2000 chars |
Resource.endpoints |
bulk cannot be enabled when create.disabled === true (refinement) |
HealthPlugin |
paths must start with / and be URL-safe; livenessPath / readinessPath / fullPath must differ |
MemoryIdempotencyPlugin / RedisIdempotencyPlugin |
methods are z.enum(['GET','POST',…]), header is a lowercase HTTP token, onConflict.status and onInFlight.status are bounded to [400, 599], ttlMs > 0 |
RedisIdempotencyPlugin.keyPrefix |
no whitespace |
RedisPlugin.client |
z.custom<RedisClient> with a duck-type check for set/get/del |
PostgresStorePlugin.pool |
z.custom<PgPool> with a duck-type check for .query() |
MongoStorePlugin.db |
z.custom<MongoDb> with a duck-type check for .collection() |
| rule | how |
|---|---|
Plugin name unique within its scope |
Registry keeps a name map per scope |
Plugin multiple: false → at most one per scope |
Registry rejects on a second insert |
| Plugin placed in unsupported scope | Registry checks each instance's allowedScopes |
dependsOn satisfied in declaration order |
composeFlat walks names + kinds left-to-right |
| Resource base paths don't collide with each other | zodop() builds a seenBases map |
Resource base paths don't clash with /openapi.json, /docs, /livez, /readyz, /health, /metrics |
RESERVED_PATHS check in zodop() |
All errors are thrown before any Fastify route mounts, with messages that name the resource / plugin / field that failed.
const Post = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
content: z.string(),
authorId: z.string().uuid(),
tags: z.array(z.string()).default([]),
publishedAt: z.coerce.date().nullable().default(null),
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullable().default(null),
});
const store = new PostgresStorePlugin({ pool });
const redis = new RedisPlugin({ client });
const locks = new RedisLockPlugin({ redis });
await zodop({
title: 'My API',
version: '1.0.0',
plugins: [
// API-scope
new SwaggerPlugin(),
new RedisIdempotencyPlugin({ redis }),
new HealthPlugin({
checks: [
/* ... */
],
}),
],
resources: [
new Resource({
name: 'post',
schema: Post,
store,
locks,
softDelete: true, // schema has deletedAt ⇒ TS allows it
plugins: [
/* resource-scope */
],
endpoints: {
// endpoint-scope
delete: {
plugins: [
/* ... */
],
},
bulk: { disabled: true },
},
hooks: { beforeCreate: data => /* ... */ data },
}),
],
});Plugin (abstract root — static kind / multiple / scopes / configSchema)
├── StorePlugin ['store', multiple: true ]
│ MemoryStorePlugin, SqliteStorePlugin, PostgresStorePlugin, MongoStorePlugin
├── LockPlugin ['lock', multiple: true ]
│ MemoryLockPlugin, RedisLockPlugin
├── IdempotencyPlugin ['idempotency', multiple: false]
│ MemoryIdempotencyPlugin, RedisIdempotencyPlugin
├── RedisPlugin ['redis', multiple: true ]
├── SwaggerPlugin ['swagger', multiple: false, scopes: ['api']]
└── HealthPlugin ['health', multiple: false, scopes: ['api']]
A user-written plugin is a subclass with its own static configSchema:
class SqlServerStorePlugin extends StorePlugin {
static override readonly configSchema = z.object({
name: z.string().regex(/^[a-z][a-z0-9_-]*$/i).default('sqlserver'),
pool: z.custom<SqlPool>(v => /* duck-type check */),
schemaName: z.string().default('dbo'),
});
// …
}Drop new SqlServerStorePlugin({ pool }) into Resource({ store }) —
the rest of the app doesn't know or care which concrete class it is.
Plugins fall into two roles, even though they all extend Plugin:
| role | examples | how they're consumed |
|---|---|---|
| capability | StorePlugin, LockPlugin, RedisPlugin |
referenced by name or by reference from a Resource or another plugin. They provide a service (persistence, mutual exclusion, a client connection). |
| middleware | SwaggerPlugin, HealthPlugin, IdempotencyPlugin, user-written auth / rate-limit |
placed into one of the three scopes (API / resource / endpoint) so their hooks run on every request inside that scope. |
Capability plugins don't need to be listed in zodop({ plugins }) — pass
them directly to the Resource (store, locks) or to another plugin
(redis), and zodop() deduplicates and registers each one exactly once
at the API scope. That means you can share a single MemoryStorePlugin
between two resources, or pass the same RedisPlugin to both
RedisLockPlugin and RedisIdempotencyPlugin, without registering it
twice or causing a name collision.
Middleware plugins are always placed explicitly, because where they run is what they do.
?sort=-createdAt,title and ?filter[field]=value are validated
against two allowlists on ResourceOptions, both typed against the
schema keys so unknown fields are a compile-time error:
new Resource({
name: 'post',
schema: Post,
store,
sortableFields: ['createdAt', 'title'], // default: all keys
filterableFields: ['authorId', 'publishedAt'], // default: all keys
});Requests that reference an unlisted field get a 400 bad_request with
the allowed set echoed in details. Default is permissive (every
schema key); tighten by listing only the fields you really want to
expose to indexed queries.
Filter values are coerced through the matching Zod field schema, so
?filter[priority]=5 against a z.number() field compares as 5, not
"5". Repeat a filter key to get IN semantics —
?filter[priority]=1&filter[priority]=3 selects rows with priority 1
or 3. An uncoercible value (?filter[priority]=banana) returns 400.
sortDefault is applied when the client omits ?sort=; pair it with
defaultPageSize / maxPageSize to cap the list surface:
new Resource({
name: 'post',
schema: Post,
store,
sortableFields: ['createdAt', 'title'],
sortDefault: '-createdAt', // newest first by default
defaultPageSize: 25, // default pageSize
maxPageSize: 100, // upper bound; requests over → 400
});By default each new row gets crypto.randomUUID(). Supply
idGenerator to plug in ULID, nanoid, a sequence, or anything else —
synchronous or Promise-returning:
import { ulid } from 'ulid';
new Resource({
name: 'post',
schema: Post,
store,
idGenerator: () => ulid(),
});Precedence is beforeCreate-supplied id → idGenerator →
crypto.randomUUID(), so a hook can still force an id for a specific
insert.
readOnly: true disables every mutation endpoint (create, bulk, update,
replace, delete) in one line — useful when the resource is populated
from an external source or a read-replica:
new Resource({
name: 'exchange-rate',
schema: ExchangeRate,
store,
readOnly: true, // only GET /exchange-rates + GET /exchange-rates/:id
});hidden: true keeps every route working but removes them from the
generated OpenAPI document — for internal or admin resources whose
existence shouldn't be advertised:
new Resource({
name: 'admin-user',
plural: 'admin-users',
schema: AdminUser,
store,
hidden: true,
});Combine them (readOnly: true, hidden: true) for a fully internal,
read-only surface.
Endpoint-level plugins apply to a single operation. A guard placed
on endpoints.create does not run on POST /:plural/batch — bulk
is a separate endpoint with its own scope.
To guard both operations, either list the plugin on each endpoint or — the usual fix — place it at the resource scope:
new Resource({
name: 'post',
schema: Post,
store,
// ✅ Resource-level: runs for every operation on /posts*, including /batch.
plugins: [new BearerGuardPlugin({ accept: ['admin'] })],
// ❌ Endpoint-level create-only — bulk/batch stays unguarded.
// endpoints: { create: { plugins: [new BearerGuardPlugin(...)] } },
});Rule of thumb: endpoint scope is for things that differ between
endpoints (e.g. "only delete requires an admin token"). Anything
you want uniformly on mutations belongs at the resource scope.
zod-op/testing exposes a thin Fastify-inject harness so tests can
exercise the full HTTP surface without opening a socket:
import { createTestApp } from 'zod-op/testing';
const harness = await createTestApp({
resources: [
/* … */
],
});
const res = await harness.request({
method: 'POST',
url: '/todos',
body: { title: 'x' },
});
expect(res.statusCode).toBe(201);
await harness.close();| scope | Fastify mechanics | placement |
|---|---|---|
api |
root app | zodop({ plugins: [ … ] }) |
resource |
per-resource app.register() scope |
new Resource({ plugins: [ … ] }) |
endpoint |
per-operation app.register() scope |
new Resource({ endpoints: { create: { plugins: [ … ] } } }) |
Plugins whose static scopes excludes a placement scope throw at
construction time — placement bugs fail before the app runs.
src/
app/ zodop() factory, error handler, OpenAPI helpers, HttpError hierarchy
plugin/ Plugin hierarchy (base, store, lock, idempotency), context types, Registry, composeFlat
resource/ Resource class, schema-driven conditional types, schema derivation, endpoint map
crud/ internal CRUD wiring — ctx, hooks, etag, filter/sort, one route file per operation
routes/ list.ts, create.ts, read.ts, update.ts, replace.ts, delete.ts, bulk.ts
store/ Store<T> contract, sort parsing, Zod reflection helpers
plugins/ concrete plugin implementations, grouped by kind
stores/ MemoryStorePlugin, SqliteStorePlugin, PostgresStorePlugin, MongoStorePlugin
locks/ MemoryLockPlugin, RedisLockPlugin
idempotency/ MemoryIdempotencyPlugin, RedisIdempotencyPlugin, fingerprint, shared config fields
health.ts, redis.ts, swagger.ts (single-plugin files stay flat)
testing/ Fastify-inject harness for integration tests
Subpath imports mirror this layout — zod-op/plugins/stores/memory,
zod-op/plugins/locks/redis, zod-op/plugins/idempotency/memory, and so on.
Auth, CORS, rate-limit, helmet, logging, metrics, tracing — after
zodop() returns, app is a plain Fastify instance. Register your own
Fastify plugins via app.register(...), or wrap behaviour as a Plugin
subclass and place it at the API / resource / endpoint scope of your
choosing.
| Feature | ZodOP | Hand-rolled Fastify | NestJS | Hasura/PostgREST |
|---|---|---|---|---|
| Auto CRUD from schema | ✅ | ❌ | ✅ | |
| OpenAPI 3.1 from schema | ✅ | ✅ | ||
| Strict TS narrowing on misconfig | ✅ | ❌ | ❌ | |
| Idempotency-Key replay | ✅ | ❌ | ❌ | ❌ |
| ETag + If-Match | ✅ | ❌ | ❌ | ❌ |
| Per-id locking | ✅ | ❌ | ❌ | ❌ |
| RFC 5988 Link headers | ✅ | ❌ | ❌ | ❌ |
| Storage choice (Memory/SQLite/PG/Mongo) | ✅ | ✅ | ✅ | ❌ (one DB) |
| You own every Fastify hook & plugin | ✅ | ✅ | ❌ | |
| Pure ESM, no decorators, no DI | ✅ | ✅ | ❌ | n/a |
ZodOP is intentionally narrow: first-table CRUD scaffolding. Relations, transactions, GraphQL, server-side rendering, multi-tenant sharding — out of scope. For those, reach for a heavier framework.
Is this production-ready? The framework's surface is — every CRUD path, every plugin, every config branch is unit- + integration-tested at ≥ 99% line coverage. The version is 0.1.0 because the API is stabilising; minor bumps may add narrow features (cursor pagination, transaction hooks) and patch bumps fix bugs.
Does it support Postgres / Mongo / SQLite?
Yes — see the plugins/stores/{postgres,mongo,sqlite} adapters. They
duck-type the driver so any drop-in replacement (@databases/pg,
postgres, etc.) works as long as it exposes the expected verbs.
Does it support relations / joins / nested resources?
No. The Store contract is single-table. For relations, use afterRead
hooks or compose in a route handler using app.register() after
zodop() returns. A future minor version may add eager-load helpers.
Does it support cursor pagination?
Not yet — only offset (?page= / ?pageSize=) with RFC 5988 Link
headers and X-Total-Count. Cursor support is on the roadmap.
Can I use this with non-Fastify frameworks?
No. The framework is built on Fastify and uses its encapsulation
model for plugin scoping. The Zod schemas, Store<T> contract, and
helper modules are framework-agnostic if you want to lift them.
How does idempotency interact with bulk?
Identical — POST /:plural/batch is just another POST that the
idempotency plugin will replay. Each item still flows through
beforeCreate/afterCreate hooks once on the original request.
Where do I put auth?
Any of the three scopes. Simplest pattern: a Plugin subclass with a
preHandler hook that throws UnauthorizedError. Place at the API
scope to guard everything, the resource scope to guard one resource,
or the endpoint scope to guard one operation. See the Endpoint-level
plugins and bulk section for the
common gotcha.
Can I disable a single endpoint?
endpoints: { delete: { disabled: true } } for one. readOnly: true
disables all five mutations in one line.
Does it open a connection to my database?
No — you create the connection (a pg.Pool, MongoClient.db(), a
better-sqlite3 Database, an ioredis client) and hand it to the
plugin. ZodOP never connects, disconnects, or holds connections
beyond what your driver does.
Pull requests welcome. See CONTRIBUTING.md for the development setup, the test/coverage requirements, and the code style.
If you find a security issue, please follow the process in SECURITY.md. Do not open a public issue.
MIT © Pedro Zigante