@znight/openapi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ugho STEPHAN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,573 @@
1
- # Temporary Holding Version
1
+ # @znight/openapi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenAPI 3.1 for [Znight](../../README.md) HTTP applications, generated at build time from your
4
+ controllers, without describing the API twice.
5
+
6
+ Paths, parameters, coercion types, defaults, request and response bodies, uploads, statuses,
7
+ headers and access decisions come from the code that already runs: the route table of
8
+ `@znight/http`, the handler signatures, their **annotated return types**, their JSDoc and your
9
+ Standard Schemas (Zod, Valibot, ArkType...). A few `@Api*` decorators add what cannot be inferred.
10
+
11
+ - **Built at compile time.** `@znight/openapi/compiler` builds every operation and the JSON Schema
12
+ of every TypeScript type, and checks them: a response that cannot be documented, a `bigint` in a
13
+ JSON response (a guaranteed 500), duplicate operation ids or an undeclared OAuth2 scope fail the
14
+ build with a location and a hint (ZNIGHT030–036).
15
+ - **One function, one truth.** `buildOpenApiDocument(metadata)` converts the Standard Schemas with
16
+ their own library and assembles the document. The same pure function runs at boot, at build time
17
+ and in tests: the served document and the committed `openapi.json` are byte-identical.
18
+ - **Served everywhere.** `OpenApiPlugin` serves `/openapi.json` (precomputed bytes, strong ETag),
19
+ an optional YAML twin and an API reference UI (Scalar, or Swagger UI) with a strict CSP, the same
20
+ way on the node, Fastify, Express and Hono adapters, on Node.js and Bun.
21
+
22
+ ```sh
23
+ # bun
24
+ bun add @znight/core @znight/http @znight/openapi
25
+ bun add -d @znight/compiler vite
26
+ bun add @scalar/api-reference # optional: serve the UI from node_modules instead of the pinned CDN
27
+ bun add yaml # optional: YAML output
28
+ bun add @valibot/to-json-schema # optional: Valibot schemas
29
+ bun add -d @cfworker/json-schema # optional: validateResponse() in tests
30
+
31
+ # npm
32
+ npm install @znight/core @znight/http @znight/openapi
33
+ npm install -D @znight/compiler vite
34
+ npm install @scalar/api-reference # optional: serve the UI from node_modules instead of the pinned CDN
35
+ npm install yaml # optional: YAML output
36
+ npm install @valibot/to-json-schema # optional: Valibot schemas
37
+ npm install -D @cfworker/json-schema # optional: validateResponse() in tests
38
+ ```
39
+
40
+ Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). `@znight/http` is a required peer dependency; the
41
+ others are optional (`swagger-ui-dist` serves Swagger UI from `node_modules`, `vite` is needed by
42
+ the `openapi()` Vite plugin).
43
+
44
+ **Documentation:** [`@znight/openapi`](https://github.com/znight-org/znight/blob/main/docs/packages/openapi.md).
45
+ **Example:** [`storefront-api`](https://github.com/znight-org/znight/tree/main/examples/storefront-api)
46
+ (Zod and Valibot schemas, a committed `openapi.json` checked in CI, a typed client generated from it).
47
+
48
+ - [Quick start](#quick-start)
49
+ - [The document](#the-document)
50
+ - [Documenting operations](#documenting-operations)
51
+ - [What is inferred](#what-is-inferred)
52
+ - [Security](#security)
53
+ - [Plugin options](#plugin-options)
54
+ - [Injection](#injection)
55
+ - [Emitting openapi.json at build time](#emitting-openapijson-at-build-time)
56
+ - [Testing](#testing)
57
+ - [Diagnostics](#diagnostics)
58
+ - [Compiler contract](#compiler-contract)
59
+ - [Limitations](#limitations)
60
+
61
+ ## Quick start
62
+
63
+ ```ts
64
+ // src/main.ts
65
+ import { Application } from '@znight/core'
66
+ import { HttpPlugin } from '@znight/http'
67
+ import { OpenApiPlugin } from '@znight/openapi'
68
+ import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
69
+
70
+ const app = await Application.create({
71
+ metadata: __ZNIGHT_METADATA__,
72
+ plugins: [HttpPlugin.configure({ port: 3000 }), OpenApiPlugin.configure()],
73
+ })
74
+ await app.start() // GET /openapi.json, GET /docs
75
+ ```
76
+
77
+ Nothing else is needed: the title, version, description and license come from `package.json`,
78
+ parameters from the signatures, request schemas from `@Body(S)`, responses from the annotated
79
+ return types and summaries from JSDoc. The compiler extension is discovered because a scanned file
80
+ (`main.ts`) imports `@znight/openapi`.
81
+
82
+ ## The document
83
+
84
+ ```ts
85
+ // src/api.ts
86
+ import { OpenApiDocument, securityScheme } from '@znight/openapi'
87
+ import { Public } from './auth/auth.decorators.js'
88
+
89
+ /** The `x-api-key` header checked by AuthGuard. */
90
+ export const ApiKey = securityScheme('apiKey', { type: 'apiKey', in: 'header', name: 'x-api-key' })
91
+
92
+ /**
93
+ * Bookshelf API
94
+ *
95
+ * A shelf of books with a cover each. Every route needs an API key, except the public ones.
96
+ */
97
+ @OpenApiDocument({
98
+ info: { contact: { email: 'api@example.com' } }, // title and description: the JSDoc above
99
+ servers: [{ url: '/api' }],
100
+ security: [ApiKey], // default of every operation
101
+ decisions: [{ when: Public, security: [] }], // @Public() routes need nothing
102
+ tags: [{ name: 'Books', description: 'The shelf' }],
103
+ })
104
+ export class BookshelfApi {}
105
+ ```
106
+
107
+ - One `@OpenApiDocument` per application, optional. The class is never instantiated.
108
+ - `info` precedence: the options, then the JSDoc of the class (first paragraph: `title`, the rest:
109
+ `description`), then `package.json` (`name`, `version`, `description`, an SPDX `license`), then
110
+ `API` / `0.0.0`.
111
+ - `securityScheme(name, definition)` declares an OpenAPI Security Scheme Object (`apiKey`, `http`,
112
+ `oauth2`, `openIdConnect`, `mutualTLS`). The compiler reads it statically; a scheme appears in
113
+ `components.securitySchemes` as soon as something references it.
114
+
115
+ | Option | Default | Description |
116
+ | -------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
117
+ | `info` | — | Title, version, description, contact, license, terms of service |
118
+ | `servers` | — | Servers of the document (else the HTTP prefix, see below) |
119
+ | `tags` | — | Declared tags first, in this order; the used tags are added, sorted |
120
+ | `externalDocs` | — | External documentation of the API |
121
+ | `security` | none | Default requirements of every operation (entries are alternatives) |
122
+ | `decisions` | `[]` | Security derived from route decorators (`createRouteDecorator`), see [Security](#security) |
123
+ | `operationIds` | `'controller.handler'` | `'controller.handler'` (`book.list`), `'handler'` (`list`) or `'Controller_handler'` |
124
+ | `errors` | `true` | Standard error responses; `false`, or `{ schema, contentType? }` for your own envelope |
125
+ | `strict` | `false` | ZNIGHT031 and ZNIGHT032 become errors |
126
+ | `jsdoc` | `true` | Read the JSDoc of controllers, handlers and types |
127
+ | `extensions` | — | `x-*` fields of the root object |
128
+
129
+ ## Documenting operations
130
+
131
+ ```ts
132
+ import { Body, Controller, Get, HttpResponse, Param, Post, Query } from '@znight/http'
133
+ import { ApiResponse, ApiTags } from '@znight/openapi'
134
+
135
+ /** Books of the shelf. */
136
+ @Controller('/books')
137
+ @ApiTags('Books')
138
+ export class BookController {
139
+ /**
140
+ * List books
141
+ *
142
+ * Sorted and paginated.
143
+ * @param search Case-insensitive search in titles and authors.
144
+ */
145
+ @Get()
146
+ async list(
147
+ @Query('q') search?: string,
148
+ @Query('sort') sort: BookSort = 'title',
149
+ @Query('limit') limit = 20,
150
+ ): Promise<Page<BookResponse>> {
151
+ /* ... */
152
+ }
153
+
154
+ /** Get a book */
155
+ @Get('/:id')
156
+ @ApiResponse(404, { description: 'No book with this id' })
157
+ async get(@Param('id') id: number): Promise<BookResponse> {
158
+ /* ... */
159
+ }
160
+
161
+ /**
162
+ * Add a book
163
+ * @returns The new book. Its URL is in the `location` header.
164
+ */
165
+ @Post()
166
+ @ApiResponse(201, { headers: { location: { description: 'URL of the new book' } } })
167
+ @ApiResponse(409, { description: 'A book with this ISBN is already on the shelf' })
168
+ async create(@Body(CreateBook) input: CreateBook): Promise<HttpResponse<BookResponse>> {
169
+ /* ... */
170
+ }
171
+
172
+ /** Report of the shelf */
173
+ @Get('/report')
174
+ @ApiResponse<ShelfReport>(200) // documents a Web Response, whose body type the compiler cannot see
175
+ report(): Response {
176
+ /* ... */
177
+ }
178
+ }
179
+ ```
180
+
181
+ | Decorator | Target | Effect |
182
+ | -------------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
183
+ | `@OpenApiDocument(options)` | class | Options of the document |
184
+ | `@ApiOperation({ summary?, description?, operationId?, deprecated?, externalDocs?, extensions? })` | method | Overrides the JSDoc and the defaults |
185
+ | `@ApiTags(...names)` | class, method | Tags (class tags first). Without any: the controller name without its `Controller` suffix |
186
+ | `@ApiResponse<T>(status, { description?, schema?, contentType?, headers?, example? })` | method, repeatable | Declares or overrides a response (`404`, `'4XX'`, `'default'`). The body type comes from `T` or from a Standard Schema in `schema` |
187
+ | `@ApiBody<T>({ description?, required?, contentType?, schema? })` | method | The body of routes that read it themselves (`ctx.parseBody()`, `ctx.bodyStream()`) |
188
+ | `@ApiQuery<T>(name, { description?, required?, deprecated?, example? })` | method | A query parameter read outside the signature |
189
+ | `@ApiHeader<T>(name, { ... })` | class, method | A header read by a guard or an interceptor (`x-tenant`) |
190
+ | `@ApiSecurity(...requirements)` | class, method | Security of the operations; no argument means public |
191
+ | `@ApiExclude()` | class, method | Not documented |
192
+
193
+ Every decorator is a no-op marker stripped from the bundle. Type arguments (`@ApiResponse<Book[]>`)
194
+ are erased at runtime, but the compiler reads them.
195
+
196
+ **JSDoc is the first source of text** (decorators override it):
197
+
198
+ | JSDoc | OpenAPI |
199
+ | ------------------------------------ | -------------------------------------------------------------------------------- |
200
+ | First paragraph of a handler comment | `summary` |
201
+ | The rest | `description` |
202
+ | `@deprecated` on a handler | `deprecated: true` |
203
+ | `@param search Text` | `description` of the parameter (or body) the TypeScript parameter `search` feeds |
204
+ | `@returns Text` | `description` of the success response (default: the reason phrase) |
205
+ | Comment of a controller | `description` of its default tag (when it is not declared in `@OpenApiDocument`) |
206
+ | Comment of a type or property | `description`, plus the tags below |
207
+
208
+ Tags on types and properties: `@deprecated`, `@example`, `@default`, `@format` (`int32` / `int64`
209
+ make a `number` an `integer`), `@minimum`, `@maximum`, `@exclusiveMinimum`, `@exclusiveMaximum`,
210
+ `@multipleOf`, `@minLength`, `@maxLength`, `@pattern`, `@minItems`, `@maxItems`, `@uniqueItems`.
211
+ Values are JSON (`@example {"id": 1}`) or text.
212
+
213
+ ## What is inferred
214
+
215
+ **Operations.** One per route and method; `:id` becomes `{id}`, a trailing `*` becomes
216
+ `{wildcard}` (`x-znight-wildcard: true`; `{wildcard_1}` when a parameter is named `wildcard`).
217
+ Routes of one path that name its parameters differently (`GET /books/:id`,
218
+ `DELETE /books/:bookId`) are documented under one template, with a ZNIGHT035 warning: OpenAPI
219
+ forbids two templates of one path. Automatic `HEAD` / `OPTIONS` are not documented, explicit
220
+ ones are (without content: a `HEAD` response has no body). `@All` is documented for GET, POST, PUT, PATCH and DELETE when no explicit route has the
221
+ same path (ids end with the method: `proxy.forward.get`). Paths never contain the HTTP prefix: it
222
+ belongs to `servers` (plugin `servers`, then `@OpenApiDocument({ servers })`, then
223
+ `[{ url: prefix }]`).
224
+
225
+ **Parameters.**
226
+
227
+ | Handler parameter | OpenAPI |
228
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
229
+ | `@Param('id') id: number` | `{ name: 'id', in: 'path', required: true, schema: { type: 'number' } }` |
230
+ | `@Query('limit') limit = 20` | `{ in: 'query', schema: { type: 'number', default: 20 } }` |
231
+ | `@Query('tag') tags: string[] = []` | `style: 'form'`, `explode: true`, `schema: { type: 'array', items: { type: 'string' } }` |
232
+ | `@Query('sort') sort: BookSort = 'title'` | `schema: { type: 'string', enum: ['author', 'title', 'year'], default: 'title' }` |
233
+ | `@Headers('x-tenant') tenant: string` | `{ in: 'header', required: true }` |
234
+ | `@Cookie('theme') theme?: 'light' \| 'dark'` | `{ in: 'cookie', schema: { type: 'string', enum: ['dark', 'light'] } }` (also `@SignedCookie`) |
235
+ | `@Param('id', IdSchema)`, `@Query('q', S)` | `schema: { $ref }` to the **input** JSON Schema of the schema |
236
+ | `@Query(SearchQuery)` (whole object) | One parameter per property of the input JSON Schema (ZNIGHT037 for a non-object schema)¹ |
237
+ | `@Query() q: SearchParams` (TypeScript type) | One parameter per property of the type |
238
+
239
+ ¹ A property that another parameter of the operation already documents (`@Query('page')`,
240
+ `@ApiQuery('page')`, another whole-object schema) is dropped, with a ZNIGHT035 assembly warning:
241
+ OpenAPI forbids two parameters with one name and location.
242
+
243
+ Coercion plans give `string`, `number`, `integer`, `boolean`, `bigint` (`string` with a pattern),
244
+ `date` (`date-time`), enums and arrays. `Accept`, `Content-Type` and `Authorization` are never
245
+ parameters (describe credentials with a security scheme).
246
+
247
+ **Request bodies.** `@Body(S)` / `@Validate(S)`: `application/json` with the **input** schema;
248
+ `@Body() input: T`: the shape of `T` (`string`: `text/plain`, bytes: `application/octet-stream`).
249
+ Upload routes get a `multipart/form-data` object of their file fields
250
+ (`contentMediaType: application/octet-stream`, `maxItems`, `At most 2 MiB` and
251
+ `x-znight-max-size`, `encoding.contentType` from `mimeTypes`), merged with the text fields of
252
+ `@Body(Form)` (`allOf`).
253
+
254
+ **Responses.** The success status is `@HttpCode`, else 201 for POST, else 200 (204 when the
255
+ handler returns `void`; `T | undefined` also documents the empty 204). Its content comes from the
256
+ annotated return type, `Promise` unwrapped, with the media types of the serialiser:
257
+
258
+ | Return type | Success response |
259
+ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
260
+ | Interfaces, aliases, classes, records, arrays, unions... | `application/json` with the schema of the type |
261
+ | `HttpResponse<T>`, `HttpResponse<A> \| HttpResponse<B> \| C` | As `T` (`A \| B \| C`); the status is chosen at runtime: other 2XX may be declared |
262
+ | `HttpResponse<SseBody>` (`HttpResponse.sse()`) | `text/event-stream`, status 200 even on POST (unless `@HttpCode`) |
263
+ | `z.output<typeof S>`, `v.InferOutput<typeof S>`, ArkType `S.infer` | The **output** JSON Schema of `S` |
264
+ | `string`, string literals, string enums | `text/plain` (`string \| null`: `text/plain` and `application/json` for the rest) |
265
+ | Bytes, streams, `Blob`, `HttpResponse<FileBody>` | `application/octet-stream` (set the media type with `@ApiResponse(s, { contentType })`) |
266
+ | `@Sse` routes (`AsyncIterable<SseEvent<T>>`, `AsyncGenerator`...) | `text/event-stream`, the schema of `T` in `x-znight-event-schema` |
267
+ | `void`, `undefined` | No content |
268
+ | A body with `@HttpCode(204)` / `304` | ZNIGHT035 error: the serialiser drops the body of bodyless statuses |
269
+ | No annotation, `unknown`, bare `HttpResponse`, `Response`, `@Res()` | ZNIGHT031, unless `@ApiResponse` documents the success status |
270
+
271
+ `@Header` values are documented on the success response. **Standard errors** (`errors: true`):
272
+ `400` (`#/components/responses/BadRequest`) on routes that coerce or validate an input, `413` on
273
+ routes that read a body, `415` on upload routes, and `HttpErrorBody` for every declared `4XX` /
274
+ `5XX` response without schema. A custom envelope (RFC 9457 problem details) is declared once:
275
+ `errors: { schema: ProblemDetails, contentType: 'application/problem+json' }`.
276
+
277
+ **Types → JSON Schema.** Interfaces (flattened `extends`), classes (instance fields, `toJSON()`),
278
+ aliases, enums, generics (`Page<Book>` → component `Page_Book`; an anonymous type argument adds a
279
+ short hash of the instantiation: `Page<{ id: string }>` → `Page_object_1a2b3c4d`), records, tuples,
280
+ `Partial`, `Pick`, `Omit`... `T | null` gives `type: [T, 'null']`, unions of literals give `enum`,
281
+ unions of objects with a required literal property give `oneOf`, with a `discriminator` and its
282
+ `mapping` when every member is a named declaration. `Date` is a `date-time` string and `URL` a
283
+ `uri` string. Named declarations become components, named after the declaration. Standard Schemas
284
+ are named after their export, with an `XInput` variant when their input and output JSON Schemas
285
+ differ; the same name for a declaration and a schema is ZNIGHT034 at compile time, except an
286
+ interface merged with the schema it is inferred from
287
+ (`export interface User extends z.infer<typeof User> {}`), documented by the schema.
288
+
289
+ Converted schemas: `$defs` are hoisted into components. A definition with an id
290
+ (`.meta({ id: 'Address' })`) is named after it, and gets an `AddressInput` variant when it differs
291
+ between requests and responses; a root `.meta({ id })` equal to the export name is the component
292
+ itself (another id gives an alias); anonymous definitions (Zod `__schema0` of a recursive schema)
293
+ are scoped to their schema (`Tree_schema0`). Dates are `date-time` strings in responses (Zod and
294
+ Valibot) and for coerced dates in requests (`z.coerce.date()`).
295
+
296
+ ## Security
297
+
298
+ For each operation, the first rule that applies wins: `@ApiSecurity` on the route, on the
299
+ controller, then the first `decisions` entry whose route decorator is in the route's effective
300
+ metadata (route entries first), else the document `security`.
301
+
302
+ ```ts
303
+ export const OAuth = securityScheme('oauth2', {
304
+ type: 'oauth2',
305
+ flows: {
306
+ clientCredentials: {
307
+ tokenUrl: 'https://id.example.com/oauth2/token',
308
+ scopes: { reader: 'Read books', admin: 'Manage books' },
309
+ },
310
+ },
311
+ })
312
+
313
+ @OpenApiDocument({
314
+ security: [OAuth],
315
+ decisions: [
316
+ { when: Public, security: [] },
317
+ { when: Roles, security: [OAuth], scopes: 'value' }, // @Roles(['admin']) → security: [{ oauth2: ['admin'] }]
318
+ ],
319
+ })
320
+ export class BookshelfApi {}
321
+ ```
322
+
323
+ Requirements: `ApiKey` (one scheme), `[Bearer, Tenant]` (both), `{ scheme: OAuth, scopes: [...] }`;
324
+ several arguments of `@ApiSecurity` are alternatives, and `@ApiSecurity()` means public. Scopes
325
+ must be declared by a flow of the scheme and are only allowed on `oauth2` and `openIdConnect`
326
+ schemes (ZNIGHT036).
327
+
328
+ ## Plugin options
329
+
330
+ `OpenApiPlugin.configure(options | (container) => options)` (name `openapi`):
331
+
332
+ | Option | Default | Description |
333
+ | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
334
+ | `enabled` | `true` | `false` serves nothing and builds nothing (the `OpenApi` external still works) |
335
+ | `path` | `'/openapi.json'` | JSON document, relative to the HTTP prefix |
336
+ | `yaml` | `false` | A path (`'/openapi.yaml'`) serves YAML (`application/yaml`, needs `yaml`) |
337
+ | `ui` | `'scalar'` | `'scalar'`, `'swagger'`, `false`, or `{ scalar: config }` / `{ swagger: config }` (JSON configuration) |
338
+ | `uiPath` | `'/docs'` | The UI page; local assets are under `${uiPath}/_assets/` |
339
+ | `assets` | `'auto'` | `'local'` (from `node_modules`), `'cdn'` (jsDelivr, pinned with SRI), `'auto'` (local when installed) |
340
+ | `document` | `'build'` | `{ file }` serves a prebuilt `openapi.json` (no conversion at boot; a stale file is a ZNIGHT030 warning)² |
341
+ | `build` | `'boot'` | `'boot'`: a failure fails `Application.create`; `'first-request'`: built lazily, one shared promise, retried |
342
+ | `info`, `servers` | — | Runtime overrides (`info` merged, `servers` replaced) |
343
+ | `filter` | — | `(operation: OpenApiOperationView) => boolean`; unreferenced components are pruned |
344
+ | `transform` | — | `(document) => document`, last-resort escape hatch |
345
+ | `converters` | `[]` | Extra `SchemaConverter`s, tried before the built-in ones |
346
+ | `unrepresentable` | `'error'` | `'warn'`: a schema that cannot be converted becomes `{}` with a ZNIGHT037 warning |
347
+ | `meta` | `[]` | Route metadata of the docs routes (`[Public.meta()]`), seen by guards and by the route decision check |
348
+ | `guards` | `[]` | Extra guards of the docs routes (global guards still run first) |
349
+ | `cacheControl` | `'no-cache'` | `cache-control` of the document (revalidated with the ETag) |
350
+
351
+ ² `info`, `servers`, `filter` and `transform` apply to the file as to a build. Under an HTTP
352
+ prefix, the servers of the file are checked (ZNIGHT030 warnings), so that clients generated from
353
+ the file call the right paths:
354
+
355
+ - a file without `servers` is served with `[{ url: prefix }]`: emit it with the prefix
356
+ (`znight openapi --prefix /api`, `znight build --openapi --openapi-prefix /api`, or
357
+ `openapi({ prefix: '/api' })` in `vite.config`);
358
+ - a relative server that does not end with the prefix (`/v1` under `/api`) is reported;
359
+ - so is a file where no server ends with the prefix: `servers: []` (served as is), or only
360
+ absolute servers of another path (`https://api.example.com`). A relative server, an absolute one
361
+ whose path ends with the prefix (`https://api.example.com/api`) or one with variables is enough.
362
+ Behind a gateway that rewrites the paths, give the plugin its own `servers`: they replace those
363
+ of the file and only they are checked.
364
+
365
+ ```ts
366
+ OpenApiPlugin.configure(container => ({
367
+ enabled: container.get(ServerConfig).docs, // DOCS=false hides the documentation
368
+ meta: [Public.meta()], // the access group of @znight/http route decisions is required: the docs are public
369
+ ui: { scalar: { theme: 'purple' } },
370
+ document: process.env['NODE_ENV'] === 'production' ? { file: new URL('./openapi.json', import.meta.url) } : 'build',
371
+ }))
372
+ ```
373
+
374
+ The docs routes are **plugin routes** of `@znight/http`: they go through the same pipeline as the
375
+ controllers (`http:request` hook, request scope, global guards, then `guards`), are listed in
376
+ `HttpPlugin.routes` with `source: 'openapi'`, and a collision with a controller route is ZNIGHT007.
377
+ The document is served from precomputed bytes with a strong ETag (`if-none-match` → 304, `HEAD`
378
+ included). The UI page has its own `content-security-policy` (`default-src 'none'`, the inline
379
+ script allowed by hash, `connect-src` limited to the document's servers, in every build mode: the
380
+ first request of the page builds the document with `build: 'first-request'`); Scalar runs without its
381
+ fonts, telemetry nor proxy, Swagger UI without its validator. With local assets nothing is loaded
382
+ from a third party; otherwise only the pinned jsDelivr files, with Subresource Integrity. The
383
+ installed UI package is found the same way on Node.js and Bun, even when its exports map does not
384
+ export its `package.json` (`@scalar/api-reference`).
385
+
386
+ Logs: `OpenAPI document built` (debug: operations, schemas, ms), each assembly warning once,
387
+ `API reference: <address>/docs` when the server listens. Exclude the docs from request logs with
388
+ `logging.requests.ignore: e => e.route?.startsWith('/docs')`.
389
+
390
+ ## Injection
391
+
392
+ ```ts
393
+ import { Injectable, type OnReady } from '@znight/core'
394
+ import { OpenApi } from '@znight/openapi'
395
+
396
+ /** Publishes the contract to the API gateway once the service is ready. */
397
+ @Injectable()
398
+ export class GatewayRegistration implements OnReady {
399
+ constructor(
400
+ private readonly openapi: OpenApi,
401
+ private readonly gateway: GatewayClient,
402
+ ) {}
403
+
404
+ async onReady(): Promise<void> {
405
+ await this.gateway.publish(await this.openapi.json())
406
+ }
407
+ }
408
+ ```
409
+
410
+ `OpenApi` (singleton external, token `@znight/openapi#OpenApi`): `document()`, `json()`, `yaml()`
411
+ and `operations` (`OpenApiOperationView`: method, path, operation id, tags, controller, handler,
412
+ effective route metadata).
413
+
414
+ ## Emitting openapi.json at build time
415
+
416
+ ```sh
417
+ znight openapi # writes openapi.json at the project root
418
+ znight openapi -o docs/openapi.yaml # YAML, chosen by the extension (needs yaml)
419
+ znight openapi --check # CI: exit 1 when the file is missing or stale; never writes
420
+ znight openapi --prefix /api # servers default when @OpenApiDocument declares none
421
+ znight openapi --runtime bun # load the modules with Bun (default: the runtime running the CLI)
422
+ znight build --openapi # after the bundle: dist/openapi.json, next to dist/main.js
423
+ znight build --openapi --openapi-prefix /api # the same, under HttpPlugin.configure({ prefix: '/api' })
424
+ ```
425
+
426
+ Pass the `prefix` of `HttpPlugin.configure()` to every command that emits the file served with
427
+ `document: { file }`: `--prefix` (`znight openapi`), `--openapi-prefix` (`znight build`, it needs
428
+ `--openapi`: a usage error otherwise), or `prefix` (the `openapi()` Vite plugin). It becomes the
429
+ `servers` of the document when `@OpenApiDocument` declares none, so the file and a build at boot
430
+ serve the same document; without it, the plugin warns (ZNIGHT030) and serves the file with
431
+ `[{ url: prefix }]`, but clients generated from the file call the wrong paths. When the Vite config
432
+ registers its own `openapi()`, `znight build --openapi=<path>` and `--openapi-prefix` do not apply
433
+ (the CLI warns): set `openapi({ output, prefix })` there.
434
+
435
+ ```ts
436
+ // vite.config.ts
437
+ import { znight } from '@znight/compiler'
438
+ import { openapi } from '@znight/openapi/vite'
439
+ import { defineConfig } from 'vite'
440
+ import options from './znight.config.ts'
441
+
442
+ export default defineConfig({
443
+ plugins: [
444
+ znight(options),
445
+ // after the bundle is written (and after each good rebuild of znight dev); default output: dist/openapi.json
446
+ // prefix: the prefix of HttpPlugin.configure() (servers default when @OpenApiDocument declares none)
447
+ // check (CI): openapi.json must be up to date (never written); dist/openapi.json is always written
448
+ openapi({ output: ['openapi.json', 'dist/openapi.json'], prefix: '/api', check: process.env['CI'] === 'true' }),
449
+ ],
450
+ })
451
+ ```
452
+
453
+ ```ts
454
+ // programmatic API, used by the CLI and the Vite plugin
455
+ import { emitOpenApi } from '@znight/openapi/emit'
456
+
457
+ const result = await emitOpenApi({ root: process.cwd(), output: 'openapi.json', check: false, runtime: 'auto' })
458
+ // { ok, written, stale, staleFiles, document, diagnostics, warnings, outputs, runtime }
459
+ ```
460
+
461
+ Emission compiles the application like `znight generate` (`znight.config.*`, else the `znight()`
462
+ plugin of the Vite config; compile errors stop it before any module runs), then loads the
463
+ generated module in a **child process** (Bun: the compiler preload; Node.js: a Vite server in
464
+ middleware mode), so module side effects never reach the CLI or Vite process. Nothing is
465
+ instantiated: no container, no plugin, no port; `NODE_ENV` defaults to `production`. Outputs are
466
+ written atomically, only when they changed; the file is byte-identical on Node.js and Bun.
467
+
468
+ Custom converters are runtime functions: emission loads them from a module whose default export is
469
+ a `SchemaConverter[]` (`--converters src/openapi.converters.ts`, `openapi({ converters })`).
470
+
471
+ **Commit `openapi.json` and check it in CI** (`znight openapi --check`): an API change shows up in
472
+ the pull request diff, and clients can be generated from the file without starting anything.
473
+
474
+ ## Testing
475
+
476
+ `@znight/testing` needs nothing special: `TestApp` boots the plugin, `app.get(OpenApi)` reaches the
477
+ document.
478
+
479
+ ```ts
480
+ import { OpenApi } from '@znight/openapi'
481
+ import { validateResponse } from '@znight/openapi/testing'
482
+
483
+ test('GET /books/:id answers what its documentation says', async () => {
484
+ await using app = await createTestApp() // the plugins of main.ts, with OpenApiPlugin.configure({ ui: false })
485
+ const document = await app.get(OpenApi).document()
486
+ const response = await app.inject({ method: 'GET', url: '/books/1', headers: { 'x-api-key': READER_KEY } })
487
+ expect(validateResponse(document, { method: 'GET', path: '/books/{id}', response })).toEqual([])
488
+ })
489
+ ```
490
+
491
+ ```ts
492
+ import { buildOpenApiDocument } from '@znight/openapi'
493
+
494
+ test('the committed openapi.json is up to date', async () => {
495
+ const { json } = await buildOpenApiDocument(__ZNIGHT_METADATA__)
496
+ expect(json).toBe(await Bun.file(new URL('../openapi.json', import.meta.url)).text())
497
+ })
498
+ ```
499
+
500
+ `validateResponse(document, { method, path, response })` returns `{ path, message, keyword }[]`
501
+ issues: the status must be documented (exact code, then `NXX`, then `default`), the media type
502
+ documented (ranges included), documented `required` headers present and a JSON body valid against
503
+ its schema (JSON Schema 2020-12 with the optional peer `@cfworker/json-schema`: no code generation,
504
+ so it runs on Bun, Node.js and edge runtimes). `response` is an `InjectResponse` or a
505
+ `{ status, headers, body }` object.
506
+
507
+ ## Diagnostics
508
+
509
+ | Code | Name | When | Severity |
510
+ | --------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
511
+ | ZNIGHT030 | `InvalidOpenApiDefinition` | Invalid `@OpenApiDocument` options or a second one, `@Api*` outside a controller or route handler, invalid static values, a malformed `securityScheme()`, the reserved `$znight` key. At runtime: invalid plugin options, a missing OpenAPI section, a stale prebuilt file, servers not reflecting the prefix (warnings) | error / warning |
512
+ | ZNIGHT031 | `UndocumentedResponse` | The success response cannot be documented and no `@ApiResponse` covers it | warning (error with `strict`) |
513
+ | ZNIGHT032 | `UnrepresentableType` | A documented type has no JSON Schema or does not serialise as declared; a `bigint` in a JSON response is always an error | warning / error |
514
+ | ZNIGHT033 | `DuplicateOperationId` | Two operations with the same id | error |
515
+ | ZNIGHT034 | `SchemaNameConflict` | Two declarations or schemas for one component name (at compile time, or at assembly for hoisted `$defs` ids and `XInput` names) | error |
516
+ | ZNIGHT035 | `InconsistentOperation` | The documentation contradicts the route: a body on 204/304 (`@ApiResponse` or `@HttpCode`), another success status than the route's, `@ApiQuery('x')` next to `@Query('x')`, a documented `Accept` / `Content-Type` / `Authorization` header, `@ApiBody` next to a body schema. Warnings: `@ApiBody` on GET/HEAD, `@ApiExclude` with other `@Api*`, two templates of one path, a whole-object parameter documented twice (assembly) | error / warning |
517
+ | ZNIGHT036 | `InvalidSecurityRequirement` | A requirement that is not a `securityScheme()`, misplaced or undeclared scopes, one scheme name for two definitions, an invalid decision, a decision never applied (warning) | error / warning |
518
+ | ZNIGHT037 | `SchemaConversionFailed` | At assembly: no converter for a schema, a converter that throws or returns something else than an object, a whole-object query schema that is not an object | error (warning with `unrepresentable: 'warn'`) |
519
+
520
+ ```text
521
+ ✗ ZNIGHT032: ShelfStats.total is a bigint: JSON.stringify throws on bigint, so GET /books/stats (BookController.stats) would answer 500
522
+ --> src/books/book.ts:58:19
523
+ = hint: Return a number or a string (String(total)), or give the type a toJSON() method
524
+ ```
525
+
526
+ ## Compiler contract
527
+
528
+ `@znight/openapi/compiler` reads the data stored by `@znight/http/compiler` (`graph.extensions`,
529
+ see "Compiler contract for other extensions" in the `@znight/http` README) and the shapes of the
530
+ compiler (`ctx.types.shape()`), and generates `metadata.extensions.openapi` (types exported from
531
+ `@znight/openapi`):
532
+
533
+ ```ts
534
+ interface OpenApiMetadata {
535
+ readonly version: 1
536
+ /** SHA-256 (base64url) of the section, schema values replaced by their names (stale-file detection). */
537
+ readonly fingerprint: string
538
+ /** openapi ('3.1.1'), info, servers?, tags?, externalDocs?, security?, x-*. */
539
+ readonly document: OpenApiDocumentTemplate
540
+ /** Sorted by path, then method; `operation` holds schema templates. */
541
+ readonly operations: readonly OpenApiOperationDefinition[]
542
+ /** JSON Schemas of TypeScript declarations, by component name (sorted). */
543
+ readonly components: Readonly<Record<string, SchemaTemplate>>
544
+ readonly securitySchemes: Readonly<Record<string, SecuritySchemeObject>>
545
+ /** Standard Schemas (imported values) converted at assembly, sorted by name. */
546
+ readonly schemas: readonly OpenApiSchemaSlot[] // { name, schema, io, source }
547
+ readonly errors?: { readonly contentType: string; readonly schema?: string }
548
+ /** Descriptions of the default tags (JSDoc of the controllers). */
549
+ readonly tagDescriptions?: Readonly<Record<string, string>>
550
+ }
551
+ ```
552
+
553
+ Where a schema is expected, the compiler writes JSON Schema or a placeholder
554
+ `{ $znight: 'schema', name, io }`; in `parameters`, `{ $znight: 'parameters', in, name, io: 'input' }`
555
+ expands to one parameter per property. The `$znight` key is reserved. The manifest declares the
556
+ extension and the `OpenApi` external:
557
+
558
+ ```jsonc
559
+ "znight": {
560
+ "apiVersion": 1,
561
+ "compiler": "@znight/openapi/compiler",
562
+ "provides": [{ "export": "OpenApi", "plugin": "OpenApiPlugin", "scope": "singleton" }]
563
+ }
564
+ ```
565
+
566
+ ## Limitations
567
+
568
+ - OpenAPI 3.1.1 only (3.2 with `itemSchema` for streams is planned behind an option).
569
+ - Return types must be annotated: the compiler does not run the TypeScript checker.
570
+ - Controllers of compiled provider libraries are documented from their `.d.ts` signatures; their
571
+ `@Api*` decorators are not read yet.
572
+ - One document per application (use `filter` to hide operations).
573
+ - Error responses are not inferred from `throw` statements: declare them with `@ApiResponse`.