Skip to content

Commit 9421c5b

Browse files
authored
Merge pull request #72 from sacconazzo/develop
v3 - zod
2 parents 78e230b + 8b3ce5e commit 9421c5b

38 files changed

Lines changed: 3529 additions & 663 deletions

.eslintrc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
"@typescript-eslint/no-explicit-any": "off",
1919
"@typescript-eslint/ban-types": "off",
2020
"@typescript-eslint/no-var-requires": "off",
21+
"@typescript-eslint/no-require-imports": "off",
22+
"@typescript-eslint/no-unused-vars": [
23+
"warn",
24+
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
25+
],
2126
"prettier/prettier": [
2227
"error",
2328
{

CHANGELOG.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
## [3.0.0] - 2026-05-14
11+
12+
A major release introducing a **Zod-first route definition pipeline** that lives
13+
alongside the existing YAML one. Every README-documented usage from 2.3.x keeps
14+
working unchanged — the major bump reflects the new public API surface, the
15+
bundled runtime dependencies, and a small CJS-interop shape change (see
16+
*Compatibility* below).
17+
18+
### Added
19+
20+
- **Zod-first route definitions.** Declare a route once with Zod schemas and
21+
obtain three things at no extra cost:
22+
1. an OpenAPI fragment merged into the same `/api-docs/oas` document;
23+
2. per-route runtime validation with the same `{ message, errors[] }`
24+
envelope as `express-openapi-validator`;
25+
3. typed `req.params` / `req.query` / `req.body` via `z.infer`.
26+
27+
New named exports of the package main:
28+
29+
| Export | Purpose |
30+
|------------------------|----------------------------------------------------------------------|
31+
| `defineEndpoint` | Declarative wrapper for a Directus endpoint extension (recommended) |
32+
| `defineRoute` | Lower-level route registration when you already have your own router |
33+
| `registerSchema` | Declare a reusable schema as `components.schemas.<name>` (emits `$ref`) |
34+
| `z` | Re-exported zod extended with `.openapi()` metadata |
35+
| `zodValidator` | Per-slot validation middleware (for advanced use) |
36+
| `registry` | Singleton `OpenAPIRegistry` from `@asteasolutions/zod-to-openapi` |
37+
| `buildZodOasFragment` | Materialise the registry into `{paths, components, tags}` |
38+
39+
The recommended shape:
40+
41+
```js
42+
const { defineEndpoint, z, registerSchema } = require('directus-extension-api-docs');
43+
44+
module.exports = defineEndpoint('my-id', (route, { services, getSchema }) => {
45+
route({
46+
method: 'post',
47+
path: '/hello',
48+
request: { body: z.object({ name: z.string().min(1) }) },
49+
responses: { 200: { description: 'OK' } },
50+
handler: (req, res) => res.json({ message: `hi ${req.body.name}` }),
51+
});
52+
});
53+
```
54+
55+
`defineEndpoint` derives the OpenAPI prefix from `id` and replaces the
56+
boilerplate of importing `defineEndpoint` from `@directus/extensions-sdk`
57+
plus wrapping `defineRoute(router, ...)` calls inside it.
58+
59+
- `prefix` option on `defineRoute` to align the OpenAPI path with Directus's
60+
`/{extension-id}` mount when using the lower-level helper.
61+
- Marketplace metadata:
62+
- new `directus-extension-endpoint` keyword;
63+
- `bugs.url`, `engines.node ">=18"`;
64+
- `directus:extension.host` expanded from `^9.19.2` to
65+
`^9.0.0 || ^10.0.0 || ^11.0.0` (current major plus the 10.x ESU line).
66+
- Docker-based runtime playground under `playground/` (Directus `11.17.4` +
67+
SQLite) with three demo extensions:
68+
- `yaml-demo` — legacy YAML + `validate()` (POST, GET-with-param, DELETE);
69+
- `zod-demo` — `defineRoute` with prefix, security, deprecated,
70+
`discriminatedUnion`, error forwarding;
71+
- `directus-services-demo` — Zod routes calling `UsersService` against the
72+
real DB.
73+
- New README section "Zod-first routes (optional)".
74+
- Comprehensive test coverage: 112 unit and integration tests (was ~25), with
75+
dedicated regression suites for the legacy `validate()` function and the
76+
`/oas` handler.
77+
78+
### Changed
79+
80+
- The build now emits a single `dist/index.js` (~135 KB) with named exports
81+
alongside the default — same single-file pattern as 2.3.x, just with more
82+
symbols.
83+
- `validate(router, services, schema, paths?)`: the `paths` parameter is now
84+
typed as optional (it was already optional at runtime; the type signature
85+
was incorrect).
86+
- Runtime dependencies added (bundled into `dist/index.js`):
87+
`zod ^3.25.76`, `@asteasolutions/zod-to-openapi ^7.3.4`.
88+
- DevDep updates: `@directus/extensions-sdk` `^17.1.4`, `@directus/types`
89+
`^15.0.3`, `typescript` `^5.9.3` (required to parse zod 4's `.d.cts` files
90+
transitively pulled by the SDK), `@typescript-eslint/*` `^8`,
91+
`eslint-config-prettier` `^10`, `eslint-plugin-prettier` `^5`,
92+
`prettier` `^3`, `@types/node` `^22` (LTS), `pinia` `^3`, `pino` `^10`.
93+
- Project guide added (`CLAUDE.md`) for contributors.
94+
95+
### Compatibility
96+
97+
- **No change required for users on 2.3.x**. The full YAML pipeline still
98+
works: `oasconfig.yaml`, per-extension `oas.yaml`, root and bundle scans,
99+
legacy `endpoints/` layout, `useAuthentication`, `publishedTags`,
100+
`filterPaths`, `merge`.
101+
- `const { validate } = require('directus-extension-api-docs')` keeps working
102+
exactly as before.
103+
- Directus loads the extension via `require(...).default || require(...)`
104+
unchanged.
105+
106+
## [2.3.4] - 2026-04-24
107+
108+
### Fixed
109+
110+
- Pin Node.js to 20.20.2 via `.npmrc`.
111+
112+
## Earlier
113+
114+
For releases before 2.3.4 see the [Git history](https://github.com/sacconazzo/directus-extension-api-docs/commits/main).
115+
116+
[Unreleased]: https://github.com/sacconazzo/directus-extension-api-docs/compare/v3.0.0...HEAD
117+
[3.0.0]: https://github.com/sacconazzo/directus-extension-api-docs/releases/tag/v3.0.0
118+
[2.3.4]: https://github.com/sacconazzo/directus-extension-api-docs/releases/tag/v2.3.4

CLAUDE.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# CLAUDE.md
2+
3+
Guida operativa per chi sviluppa su questo repository.
4+
5+
## Cos'è
6+
7+
Directus endpoint extension che espone Swagger UI (`/api-docs`) e OpenAPI (`/api-docs/oas`) mergiando lo spec core di Directus con le definizioni custom delle altre extension. Le definizioni custom possono essere dichiarate in due modi, supportati nello stesso progetto:
8+
9+
1. **YAML**`oasconfig.yaml` + `oas.yaml` per extension; validazione runtime opzionale tramite `validate(router, services, schema, paths?)` che monta `express-openapi-validator`.
10+
2. **Zod**`defineEndpoint(id, (route, ctx) => { route({...}) })` è l'API ergonomica: deriva il prefix OpenAPI da `id`, espone `services`/`getSchema` nello scope e wrappa internamente il `defineEndpoint` del SDK. `defineRoute(router, {...})` resta come API low-level per casi misti Zod+Express raw. Tutto esposto come named export del main, accanto a `validate`: `import { defineEndpoint, defineRoute, registerSchema, z } from 'directus-extension-api-docs'`.
11+
12+
Le due strade si fondono in `src/index.ts`, route handler `GET /oas`: core spec → merge YAML (`config.paths/tags/components`) → merge `buildZodOasFragment()` → eventuale `filterPaths(publishedTags)`.
13+
14+
## Comandi (sempre `pnpm`)
15+
16+
```
17+
pnpm install
18+
pnpm test # Jest
19+
pnpm typecheck # tsc --noEmit (richiesto per i type-test in tests/zod/types.test-d.ts)
20+
pnpm lint # eslint
21+
pnpm build # directus-extension build → singolo dist/index.js
22+
pnpm dev # build watch
23+
```
24+
25+
`pnpm test`, `pnpm typecheck`, `pnpm lint` devono passare puliti prima di committare.
26+
27+
## Architettura sorgenti
28+
29+
```
30+
src/
31+
├── index.ts Entry point Directus. Esporta { id, validate, handler }.
32+
│ Handler /oas mergia core + YAML + fragment Zod in quest'ordine.
33+
├── utils.ts getConfig (scan YAML), getOas/getOasAll, merge(), filterPaths(), getPackage().
34+
├── types.ts Tipi YAML (oasConfig, oas).
35+
└── zod/ Sotto-modulo: i suoi simboli sono ri-esportati come named exports da src/index.ts.
36+
├── index.ts Barrel: esegue extendZodWithOpenApi(z); export pubblico.
37+
├── registry.ts Singleton OpenAPIRegistry; registerSchema; _resetRegistry (test only).
38+
├── validate.ts zodValidator middleware: safeParse params→query→body, envelope errori 400.
39+
├── openapi.ts buildZodOasFragment(): registry → {paths, components, tags}.
40+
├── route.ts defineRoute(): registry.registerPath + montaggio middleware su router Express.
41+
└── endpoint.ts defineEndpoint(id, setup): wrapper che ritorna {id, handler}, cura prefix `/<id>`,
42+
ed espone `route()` curried su router+prefix dentro lo setup callback.
43+
```
44+
45+
Riusa `merge()` di `utils.ts` e `filterPaths()` di `utils.ts`. Non reinventare deep-merge o filtro tag.
46+
47+
## Gotcha (cose non ovvie che bruciano tempo)
48+
49+
- **Build single-file.** `directus-extension build` (rollup del SDK) bundla `src/index.ts` con tutti gli import locali (incluso `src/zod/*`) in un unico `dist/index.js`. Le dipendenze npm (`zod`, `@asteasolutions/zod-to-openapi`, `swagger-ui-express`, ...) sono richieste a runtime. Non aggiungere step di build separati per emettere `dist/zod/*` — i simboli Zod sono esposti come named exports di `src/index.ts`.
50+
- **`@directus/extensions-sdk` è ESM.** Jest+ts-jest non lo carica in contesto CommonJS. I test che caricano `src/index.ts` devono mockarlo: `jest.mock('@directus/extensions-sdk', () => ({ defineEndpoint: (h: unknown) => h }))`.
51+
- **`getConfig()` legge da `process.cwd()` al module-load di `src/index.ts`.** Per testare diverse fixture YAML, montare `jest.spyOn(process, 'cwd')` **prima** del primo `require('../../src/index')`. ESM `import` viene hoistato e rompe l'ordine — usare `require()` esplicito al module level del test.
52+
- **Singleton `OpenAPIRegistry`.** `registry.definitions` è un **getter** che ritorna un nuovo array `[...parents, ..._definitions]`; mutarlo non resetta lo stato. `_resetRegistry()` muta `_definitions` (campo privato).
53+
- **Zod versione.** Direct dep `zod ^3.x`. `@directus/extensions-sdk` trascina transitivamente `zod@4` con `.d.cts` che richiedono TS ≥ 5 per essere parsati — devDep TypeScript `^5.x` è obbligatoria. Non passare a `zod` 4 sul proprio: `@asteasolutions/zod-to-openapi` 7.x supporta solo Zod 3.
54+
- **Envelope errori comune.** Sia `validate()` (via `express-openapi-validator`) sia `zodValidator` rispondono `400 { message, errors:[{path, message, code}] }`. `path` ha forma `/body/field`, `/params/id`, `/query/limit`. Cambiare la shape è breaking — modificare in coppia entrambi i percorsi e i test.
55+
- **`useAuthentication`.** Quando `false` (default), il handler `/oas` forza `accountability = { admin: true }` indipendentemente da `req.accountability`. Per testare il gate sull'iniezione path custom serve fixture con `useAuthentication: true`.
56+
57+
## Test
58+
59+
```
60+
tests/
61+
├── index.test.ts scan YAML, getConfig, merge, filterPaths, bundle support
62+
├── zod/
63+
│ ├── registry.test.ts singleton, .openapi(), reset, complex Zod, extend
64+
│ ├── validate.test.ts happy paths, error envelope, coercion
65+
│ ├── route.test.ts tutti i verbi (test.each), opzioni, errori sync/async
66+
│ ├── openapi.test.ts shape, ogni feature Zod, validazione OAS 3.0
67+
│ ├── integration.test.ts merge YAML+Zod, filterPaths su Zod, dedup tag, registry vuoto
68+
│ └── types.test-d.ts compile-time (verificato da tsc --noEmit), include @ts-expect-error
69+
└── legacy/
70+
├── validate.test.ts regressione express-openapi-validator (body, query, paths arg, envelope)
71+
└── oas-handler.test.ts regressione /oas (merge YAML, info, accountability gate, contributi Zod)
72+
73+
tests/mocks/
74+
├── oasconfig/, customoas/, merge/, bundle/, mixed/ fixture YAML
75+
├── zod-mixed/ fixture YAML+Zod per integration.test.ts
76+
├── legacy-validate/ fixture per tests/legacy/validate.test.ts
77+
└── legacy-oas/ fixture per tests/legacy/oas-handler.test.ts (useAuthentication: true)
78+
```
79+
80+
I test usano `supertest` + Express in-process; nessun boot di Directus.
81+
82+
## Playground (test runtime in Directus reale)
83+
84+
```
85+
playground/
86+
├── docker-compose.yml Directus 11 + SQLite, bind-mount del dist/ del repo
87+
├── .gitignore
88+
└── extensions/ Mappato su /directus/extensions
89+
├── oasconfig.yaml Config YAML root + securitySchemes per il lock in Swagger
90+
├── yaml-demo/ POST /echo, GET /users/:id (path param + 200/404), DELETE /items/:id
91+
│ ├── package.json
92+
│ ├── index.js
93+
│ └── oas.yaml
94+
├── zod-demo/ 8 rotte: GET/POST/PUT/DELETE, discriminatedUnion, security, deprecated, throw
95+
│ ├── package.json
96+
│ └── index.js
97+
└── directus-services-demo/ Rotte Zod che chiamano UsersService (DB SQLite reale)
98+
├── package.json
99+
└── index.js
100+
```
101+
102+
Uso (dalla root del repo):
103+
```
104+
pnpm build # produce dist/index.js
105+
docker compose -f playground/docker-compose.yml up # boot Directus su :8055
106+
```
107+
108+
L'image è pinnata a `directus/directus:11.17.4` (current stable 11.x — Directus 12 non è ancora rilasciato; 10.x è in ESU).
109+
Poi `http://localhost:8055/api-docs` (Swagger), `http://localhost:8055/api-docs/oas` (spec), e prova le rotte demo. `EXTENSIONS_AUTO_RELOAD=true` rilegge dist senza restart del container — basta rifare `pnpm build`. Modifiche a `playground/extensions/*/index.js` o `oas.yaml` non richiedono build, vengono prese al volo.
110+
111+
I demo importano `directus-extension-api-docs` via il bind-mount di `package.json` + `dist/` su `extensions/node_modules/directus-extension-api-docs/` dentro il container; nessun `npm install` lato playground è necessario.
112+
113+
## Convenzioni
114+
115+
- Niente nuovi file Markdown a meno che esplicitamente richiesto.
116+
- Aggiornamenti al README in modo misurato: la sezione "Zod-first routes (optional)" sta in fondo, non sostituisce niente, non marca YAML come deprecato.
117+
- `noUnusedLocals` / `noUncheckedIndexedAccess` attivi in tsconfig: usare `?.`, `??`, e prefisso `_` per parametri non usati.
118+
- Commit message: stile esistente del repo (`feat:`, `fix:`, `docs:`, ...).

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
> Compatible with latest Directus versions and packaged extensions.
44
5+
Release notes: see [CHANGELOG.md](./CHANGELOG.md).
6+
57
Directus Extension providing:
68

79
- a Swagger UI interface (OpenAPI 3.x)
810
- an autogenerated OpenAPI specification file (merged core + your custom endpoints)
911
-- including custom endpoint definitions
1012
- optional validation middleware for your custom endpoints (based on merged OpenAPI spec). See details below
13+
- optional Zod-first route definitions for type-safe validation + OpenAPI auto-generation (see [Zod-first routes](#zod-first-routes-optional))
1114

1215
![workspace](assets/swagger.png)
1316

@@ -19,7 +22,9 @@ Ref: https://github.com/directus/directus
1922

2023
## Installation
2124

25+
```
2226
npm install directus-extension-api-docs
27+
```
2328

2429
- Swagger interface: by default `http://localhost:8055/api-docs`
2530
- Openapi documentation: by default `http://localhost:8055/api-docs/oas`
@@ -196,3 +201,54 @@ export default {
196201
},
197202
}
198203
```
204+
205+
## Zod-first routes (optional)
206+
207+
An alternative ergonomic API: declare schemas and handlers together. The OpenAPI fragment is generated and merged into the same spec served at `/api-docs/oas`, and request validation runs automatically before each handler. Everything you need ships from this package — no need to import `defineEndpoint` from the Directus SDK.
208+
209+
```ts
210+
const { defineEndpoint, z, registerSchema } = require('directus-extension-api-docs');
211+
212+
const UserId = registerSchema(
213+
'UserId',
214+
z.object({
215+
user_id: z.string().uuid().openapi({ example: '63716273-0f29-4648-8a2a-2af2948f6f78' }),
216+
}),
217+
);
218+
219+
module.exports = defineEndpoint('my-custom-path', (route, { services, getSchema }) => {
220+
route({
221+
method: 'post',
222+
path: '/my-endpoint',
223+
tags: ['MyCustomTag'],
224+
summary: 'Validate user id',
225+
security: [{ Auth: [] }],
226+
request: { body: UserId },
227+
responses: {
228+
200: { description: 'OK', schema: UserId },
229+
401: { description: 'Unauthorized' },
230+
},
231+
handler: async (req, res) => {
232+
// req.body is typed: { user_id: string }
233+
res.json({ user_id: req.body.user_id });
234+
},
235+
});
236+
});
237+
```
238+
239+
Notes:
240+
- The OpenAPI prefix defaults to `/<id>` so paths in `/api-docs/oas` match the URLs clients actually call (Directus mounts each endpoint extension under `/<id>`).
241+
- `services`, `getSchema`, `logger`, ... are available in the setup closure and naturally accessible from each handler.
242+
- For finer control (e.g. mixing Zod and raw Express routes), the lower-level `defineRoute(router, config)` is still exported.
243+
244+
Public exports (named exports of the package main, alongside `validate`):
245+
246+
| Export | Purpose |
247+
| ----------------- | -------------------------------------------------------------------------------- |
248+
| `defineEndpoint` | Declarative wrapper for a Directus endpoint extension built on Zod routes. |
249+
| `defineRoute` | Lower-level route registration when you already have your own router. |
250+
| `registerSchema` | Register a reusable Zod schema as `components.schemas.<name>` (emits `$ref`). |
251+
| `z` | Re-exported `zod` already extended with `.openapi()` metadata. |
252+
| `zodValidator` | The per-slot validation middleware used internally, for advanced use. |
253+
254+
Validation errors are returned as HTTP `400` with the same `{ message, errors[] }` envelope used by `express-openapi-validator`, so existing API consumers don't need to change. Coexists with YAML definitions: pick whichever fits each endpoint.

0 commit comments

Comments
 (0)