Skip to content

zigante/zodop

Repository files navigation

ZodOP — Zod-Oriented Programming

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 version License: MIT Node ≥ 20 TypeScript Coverage ≥ 99% lines / 95% branches

npm install zod-op zod fastify
# or
yarn add zod-op zod fastify
import { 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 /docs

That'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.


Why ZodOP

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'.
});

Everything the schema (and Zod) decides for you

Compile-time (TypeScript)

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

Construction-time (Zod refinements)

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()

Compose-time (zodop())

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.


The shape

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 hierarchy

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.

Capability plugins vs middleware plugins

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.

Query allowlists

?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
});

Custom id generation

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 → idGeneratorcrypto.randomUUID(), so a hook can still force an id for a specific insert.

View-only and internal resources

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 and bulk

Endpoint-level plugins apply to a single operation. A guard placed on endpoints.create does not run on POST /:plural/batchbulk 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.

Testing

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();

Three scopes, like Express middleware

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.

File layout

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.

Anything else is your Fastify

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.

How does it compare?

Feature ZodOP Hand-rolled Fastify NestJS Hasura/PostgREST
Auto CRUD from schema ⚠️ via libs
OpenAPI 3.1 from schema ⚠️ manual ⚠️ manual
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.

FAQ

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.

Contributing

Pull requests welcome. See CONTRIBUTING.md for the development setup, the test/coverage requirements, and the code style.

Security

If you find a security issue, please follow the process in SECURITY.md. Do not open a public issue.

License

MIT © Pedro Zigante

About

Zod-Oriented Programming — turn one Zod schema into a complete REST resource

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors