@tetsujs/openapi 0.6.0 → 0.6.2

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 (2) hide show
  1. package/README.md +11 -445
  2. package/package.json +4 -4
package/README.md CHANGED
@@ -1,465 +1,31 @@
1
1
  # @tetsujs/openapi
2
2
 
3
- An OpenAPI 3.1 document and a docs page, generated from the routes you
4
- already declared.
3
+ An OpenAPI 3.1 document and docs page generated from [Tetsu](https://tetsujs.com) routes.
5
4
 
6
5
  ```bash
7
6
  bun add @tetsujs/openapi
8
7
  ```
9
8
 
10
- ## Usage
11
-
12
- Mount the `docs()` controller next to your own:
13
-
14
9
  ```ts
15
10
  import { docs } from "@tetsujs/openapi";
16
11
 
17
12
  createApp({
18
13
  routes: [
19
- group("/api", { children: [usersController({ users })] }),
14
+ group("/api", { children: [usersController()] }),
20
15
  docs({ info: { title: "Users API", version: "1.0.0" } }),
21
16
  ],
22
17
  });
23
18
  ```
24
19
 
25
- `/openapi.json` serves the document and `/docs` renders it. The document
26
- is built once, at startup, so anything that cannot be described is
27
- reported before the first request.
28
-
29
- `docs()` is an ordinary controller: put it in a group to move it under a
30
- prefix, guard it with hooks, or leave it out in production. Its own two
31
- routes are left out of the document.
32
-
33
- ### The page runs someone else's code on your origin
34
-
35
- The page loads its renderer — Scalar, Swagger UI or Redoc — from
36
- jsDelivr and runs it on the application's origin, with that origin's
37
- cookies. If a session cookie lives there, a renderer that is not what it
38
- should be acts as the signed-in user. The defaults are pinned to an exact
39
- version with a Subresource Integrity hash, so a file the CDN changes is
40
- refused by the browser; that does not make the renderer yours, and what it
41
- loads in turn is not covered.
42
-
43
- Where the origin carries a session, serve the document alone:
44
-
45
- ```ts
46
- docs({ info, ui: false });
47
- ```
48
-
49
- `/openapi.json` is served and there is no page. For a page anyway, host
50
- the renderer yourself or pin your own copy, with its hash:
51
-
52
- ```ts
53
- docs({
54
- info,
55
- assets: {
56
- script: "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.72.1/dist/browser/standalone.js",
57
- integrity: { script: "sha384-…" },
58
- },
59
- });
60
- ```
61
-
62
- `curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A` prints
63
- the hash, to be prefixed with `sha384-`.
64
-
65
- ## Options
66
-
67
- | Option | Default | |
68
- | --- | --- | --- |
69
- | `info` | — | the document's `info`: title, version, description |
70
- | `servers` | — | where the API is reachable |
71
- | `path` | `/openapi.json` | where the document is served |
72
- | `uiPath` | `/docs` | where the page is served |
73
- | `ui` | `"scalar"` | `"scalar"`, `"swagger-ui"`, `"redoc"`, or `false` for no page |
74
- | `title` | the document's title | the page's title |
75
- | `assets` | the renderer on jsDelivr, pinned | your own renderer URLs, with `integrity` hashes |
76
- | `documentSelf` | `false` | include `/openapi.json` and `/docs` in the document |
77
- | `onWarning` | `console.warn` | receives what could not be described |
78
- | `errors` | the framework's envelope | an error format of your own — see [below](#an-error-format-of-your-own) |
79
- | `tags` | — | what each tag is, in the order the sidebar lists them — see [below](#tags) |
80
-
81
- ## How a route becomes an operation
82
-
83
- Every part of an operation comes from somewhere the application already
84
- wrote, by a rule:
85
-
86
- | In the document | Comes from | Rule |
87
- | --- | --- | --- |
88
- | the path | the route's `path`, under its groups' prefixes | `/users/:id` → `/users/{id}` |
89
- | `operationId` | `docs.operationId`, or the controller's name and the field | `setAvatar` in `controller("Users", …)` → `usersSetAvatar` — see [Operation ids](#operation-ids) |
90
- | `summary`, `description`, `tags`, `deprecated` | the route's `docs` | as written |
91
- | parameters | `schema.params`, `query`, `headers`, `cookies` | required as the schema says |
92
- | the request body | `schema.body` and `bodyType` | media types from `bodyType`; required unless `text` or `stream` |
93
- | `security` | `secured()` hooks, of every level the route runs under | every hook required together; `anyOf` inside one hook is the alternatives |
94
- | the statuses | the route's `schema.response`, the hooks' `documented()` and `secured()`, the framework | one schema is `200`, a map its statuses, `null` or an entry without `body` a status without a body; with no `schema.response` at all, `200`; the framework's `422`, `400`, `413`, `500` where they can happen — see [Responses the framework adds](#responses-the-framework-adds) |
95
- | a status's body | everything answering with that status | one flat `anyOf`, the route's own first, the same body once |
96
- | an error envelope | any of them | one definition per status and code in `components`, named after the code; the route's own kept |
97
- | `discriminator` | the envelopes of a status | on `error`, or the format's field, when every alternative is an envelope |
98
- | a status's description | the schemas' `description`, the hooks', the framework's | one is the description, several a list led by code, none the reason phrase |
99
- | a status's headers | the route's entry — `{ body, headers, cookies }` — and the hooks' `documented()` headers | the route's required as its schema says, a property's `description` the header's; its cookies one `set-cookie` header listing them; a hook's possible, not required; the route's wins a name both give |
100
- | the document's `tags` | the `tags` option | in its order, then the tags routes use and it leaves out |
101
-
102
- `docs: { hidden: true }` leaves a route out of the document; it is served
103
- as before. A route with no `schema.response` is documented as `200` — its
104
- handler may still answer `204` by returning nothing, which only a declared
105
- `204: null` says.
106
-
107
- ## Tags
108
-
109
- A route names its tags in `docs: { tags }`; the document says what each
110
- one is:
111
-
112
- ```ts
113
- docs({
114
- info,
115
- tags: {
116
- "sign-in": "Signing in with a code sent by email, and signing out",
117
- me: "The signed-in user",
118
- },
119
- });
120
- ```
121
-
122
- The order of the keys is the order of the sections a renderer lists. A tag
123
- the routes use and `tags` leaves out is listed after them, in the order
124
- the routes first use it, and reported — it is usually one tag spelled two
125
- ways, `sign_in` next to `sign-in`. So is a tag described and used by no
126
- operation. Without `tags` the document lists none, and nothing is
127
- reported.
128
-
129
- A `docs()` documents the application it is mounted in, all of it. Several
130
- surfaces with a document each — a public API and an admin one — are
131
- several applications, each with its own `docs()` and only the tags of its
132
- own routes:
133
-
134
- ```ts
135
- const api = Bun.serve({
136
- ...createApp({ routes: [orders, docs({ info, tags: { orders: "Orders" } })] }),
137
- port: 3000,
138
- });
139
-
140
- const admin = Bun.serve({
141
- ...createApp({ routes: [users, docs({ info: adminInfo, tags: { users: "Users" } })] }),
142
- port: 3001,
143
- });
144
- ```
145
-
146
- ## Operation ids
147
-
148
- A generated client names its methods after the `operationId`s, so they are
149
- a contract, and every one comes from a name you wrote:
150
-
151
- | Route | `operationId` |
152
- | --- | --- |
153
- | with `docs: { operationId }` | as written |
154
- | in `controller("Users", …)` as `setAvatar` | `usersSetAvatar` |
155
- | in a class `UsersController` as `setAvatar` | `usersSetAvatar` |
156
- | in an object literal as `setAvatar` | `setAvatar` |
157
- | mounted on its own, `POST /auth/code` | `postAuthCode` |
158
-
159
- Two routes arriving at the same id stop the application at startup, naming
160
- both — nothing is renamed behind your back, which would change a method in
161
- someone's SDK the day a route is added. Give one of them
162
- `docs.operationId`, or its controller a name of its own. On a public API,
163
- state the id on every route: it is then visible, and a change to it is a
164
- change a reviewer sees.
165
-
166
- ```ts
167
- route({
168
- method: "POST",
169
- path: "/internal/drain",
170
- docs: { summary: "Drain the queue", hidden: true },
171
- handler: () => queue.drain(),
172
- });
173
- ```
174
-
175
- Schemas are described through Standard Schema's JSON Schema support: Zod,
176
- Valibot, ArkType and [`@tetsujs/typebox`](../typebox) provide it.
177
-
178
- ## Responses the framework adds
179
-
180
- Failures the framework answers by itself are documented on every route
181
- where they can happen, in the same error envelope the server sends:
182
-
183
- | Status | `error` | When |
184
- | --- | --- | --- |
185
- | `422` (or the configured validation status) | `VALIDATION_FAILED` | a request part failed its schema |
186
- | `400` | `MALFORMED_JSON` / `MALFORMED_FORM` | the body could not be parsed |
187
- | `413` | `BODY_TOO_LARGE` | the body exceeded `maxBodySize` |
188
- | `500` | `INTERNAL_SERVER_ERROR` | any unhandled failure |
189
-
190
- The `error` code is a constant in each schema, so a generated client can
191
- tell failures apart by it. A status the route declares itself is kept, and
192
- the framework's failures for the same status are listed next to it.
193
-
194
- Every envelope — the framework's, a hook's, or one the route declares
195
- itself — is one definition in `components` per status and code, named
196
- after the code (`ITEM_NOT_FOUND` is `ItemNotFound`), so a generated client
197
- gets one type per failure. A schema is recognized as an envelope when its
198
- `error` is required and a single string. When the route and a hook both
199
- describe one code, the route's definition is kept; if the other one has
200
- different fields, the generator warns. A union the route declares joins
201
- the other failures of its status as one flat `anyOf`.
202
-
203
- When every alternative of a status is an envelope, the union carries a
204
- `discriminator` on `error`, with the mapping from each code to its
205
- definition, and a generated client narrows on the code.
206
-
207
- A status is described by what answers with it. The route's part is the
208
- `description` of the schema it declares — `.describe()` in Zod and
209
- ArkType, `v.description()` in Valibot, `{ description }` in TypeBox — for
210
- any status, a `200` as much as a `404`; a union described as a whole is
211
- one description, one described branch by branch is one per branch. A
212
- hook's part is its `documented()` description, the framework's its own.
213
- One description is the status's description; several are a list, each
214
- led by its code:
215
-
216
- ```md
217
- - `ACCOUNT_DISABLED`: this account is disabled
218
- - `CAPTCHA_FAILED`: the captcha token is missing or did not pass
219
- ```
220
-
221
- A status nothing describes keeps its reason phrase — `Not Found`,
222
- `Successful response`.
223
-
224
- ## An error format of your own
225
-
226
- Every failure reaches the application's `onError` hooks — a thrown
227
- `HttpError`, a validation or body failure, a `404`, a `405`, a rate limit's
228
- refusal, and an error nothing expected — so one hook answers all of them in
229
- your format:
230
-
231
- ```ts
232
- import type { ErrorBody } from "@tetsujs/core";
233
- import { hook, HttpError, reportFailure } from "@tetsujs/core";
234
-
235
- const inOurFormat = hook.onError((ctx) => {
236
- const { error } = ctx;
237
-
238
- if (error instanceof HttpError) {
239
- const { status, error: code, ...rest } = error.body as ErrorBody;
240
-
241
- return Response.json({ code, ...rest }, { status });
242
- }
243
-
244
- reportFailure(ctx, "unhandled", error);
245
-
246
- return Response.json(
247
- { code: "INTERNAL_SERVER_ERROR", message: "Internal Server Error" },
248
- { status: 500 },
249
- );
250
- });
251
- ```
252
-
253
- An error the hook answers is one nothing else reports: `reportError` hears
254
- of a failure only when no `onError` hook answered it. So the hook that
255
- answers the unexpected ones — a database gone, a `TypeError` — reports them
256
- itself; left out, they answer in your format and never reach the error
257
- tracker.
258
-
259
- The document is generated from the routes, not from that hook, so it is
260
- told the same format with `errors`:
261
-
262
- ```ts
263
- const errors: ErrorFormat = {
264
- schema: ({ error, message, fields }) => ({
265
- type: "object",
266
- required: ["code", "message", ...Object.keys(fields)],
267
- properties: {
268
- code: error ? { type: "string", const: error } : { type: "string" },
269
- message: { type: "string", ...(message ? { examples: [message] } : {}) },
270
- ...fields,
271
- },
272
- }),
273
- discriminator: "code",
274
- };
20
+ ## Documentation
275
21
 
276
- createApp({
277
- hooks: { onError: [inOurFormat] },
278
- routes: [api, docs({ info, errors })],
279
- });
280
- ```
281
-
282
- `schema` describes one failure from what the generator knows of it: its
283
- status, its code when known, an example message, and in `fields` what it
284
- carries besides — `issues` for a validation failure, `retryAfter` for a
285
- rate limit, a hook's own `fields`. It is used for the framework's failures
286
- and the hooks' refusals alike.
287
-
288
- `discriminator` names the top-level field that holds the code. Statuses of
289
- envelopes are then discriminated on it, and a route's own envelope is
290
- recognized by it — a `{ code: "ITEM_NOT_FOUND", … }` the route declares is
291
- the same definition as a hook's. A format that nests its code has no
292
- top-level field to name; it gives `code` instead, reading the code from a
293
- route's schema:
294
-
295
- ```ts
296
- code: (schema) => {
297
- const error = schema.properties?.error;
298
- const code = typeof error === "object" ? error.properties?.code : undefined;
299
-
300
- return typeof code === "object" && typeof code.const === "string" ? code.const : undefined;
301
- },
302
- ```
303
-
304
- The hook and `errors` describe one format in two places — the core knows
305
- nothing about documents, and a function cannot be read for the shape it
306
- returns — so keep a test that holds them together: provoke the failures,
307
- the unexpected one included, and check each against the document with
308
- [`assertDescribed`](#testing-against-the-document):
309
-
310
- ```ts
311
- const boom = route({ method: "GET", path: "/boom", handler: () => { throw new Error("boom"); } });
312
- const app = createApp({ hooks: { onError: [inOurFormat] }, routes: [api, boom] });
313
- const request = serve(app);
314
- const { document } = openapi(app, { info, errors });
315
-
316
- test("failures are what the document says", async () => {
317
- await assertDescribed(document, "POST /items", await request("/items", { method: "POST", body: "{}" }));
318
- await assertDescribed(document, "GET /boom", await request("/boom"));
319
- });
320
- ```
321
-
322
- ## Documenting hooks
323
-
324
- A hook that answers by itself — an auth check, a limiter — can say so, and
325
- every route it guards is documented accordingly. `secured()` adds a
326
- security scheme, `documented()` adds responses:
327
-
328
- ```ts
329
- import { documented, secured } from "@tetsujs/openapi";
330
-
331
- export const auth = secured(
332
- hook.beforeParse((ctx) => {
333
- const user = verify(ctx.req.headers.get("authorization"));
334
-
335
- if (!user) throw new HttpError(401);
336
-
337
- return { user };
338
- }),
339
- { name: "bearerAuth", scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" } },
340
- );
341
-
342
- export const guard = documented(hook.beforeParse(check), {
343
- responses: [
344
- {
345
- status: 429,
346
- description: "Rate limit exceeded",
347
- error: "RATE_LIMITED",
348
- fields: { retryAfter: { type: "integer", minimum: 0 } },
349
- headers: { "retry-after": { schema: { type: "integer", minimum: 0 } } },
350
- },
351
- ],
352
- });
353
- ```
354
-
355
- A response with an `error` code is the framework's envelope, defined once
356
- in `components` and named after the code. `fields` adds what the hook puts
357
- next to `status`, `message` and `error`, each always present; `headers`,
358
- what it sets on the response. Both are JSON Schema written by hand, typed
359
- keyword by keyword (`JsonSchema`), so a misspelled keyword does not
360
- compile. A hook whose body is not the envelope passes a `schema` instead.
361
-
362
- Hooks from `@tetsujs/rate-limit` are already documented this way. A scheme
363
- is identified by its `name`: the same one mounted twice is one
364
- requirement, and two different schemes under one name are reported.
365
-
366
- Every hook of a route runs, so every scheme its hooks carry is required
367
- together: a route behind a CSRF check and a captcha is documented as
368
- needing both, one entry of `security` — in OpenAPI, separate entries mean
369
- any one of them will do. Two hooks of one scheme require the scopes of
370
- both.
371
-
372
- "Either" lives inside one hook: a hook that accepts a session cookie or a
373
- bearer token says so with `anyOf`, and the document lists every
374
- combination a client may bring:
375
-
376
- ```ts
377
- export const caller = secured(hook.beforeParse(sessionOrToken), {
378
- anyOf: [cookieSession, bearerToken],
379
- });
380
-
381
- // with a CSRF check on the same route:
382
- // security: [{ session: [], csrf: [] }, { bearer: [], csrf: [] }]
383
- ```
384
-
385
- ## Using the document directly
386
-
387
- When the document itself is the output — written to a file in CI, fed to a
388
- client generator — call the generator:
389
-
390
- ```ts
391
- import { openapi } from "@tetsujs/openapi";
392
-
393
- const { document, warnings } = openapi(app, {
394
- info: { title: "Users API", version: "1.0.0" },
395
- });
396
-
397
- await Bun.write("openapi.json", JSON.stringify(document, null, 2));
398
- ```
399
-
400
- `docsPage()` renders the page on its own, for serving it elsewhere.
401
-
402
- ## Testing against the document
403
-
404
- `@tetsujs/openapi/testing` checks a response a test provoked against the
405
- operation the document describes:
406
-
407
- ```ts
408
- import { assertDescribed } from "@tetsujs/openapi/testing";
409
-
410
- const { document } = openapi(app, { info });
411
-
412
- test("a wrong code is what the document says", async () => {
413
- const res = await request("/session", { method: "POST", body });
414
-
415
- await assertDescribed(document, "POST /session", res);
416
- });
417
- ```
418
-
419
- It throws unless the status is declared and the body fits one of the
420
- alternatives its status describes, listing every problem at once:
421
-
422
- ```
423
- POST /session answered 403, which the document does not describe:
424
- - error "CAPTCHA_FAILED", which its 403 does not list: ACCOUNT_DISABLED, CSRF_HEADER_REQUIRED
425
- ```
426
-
427
- The operation is named as the test called it, a method and the path it
428
- requested, matched against the document's templates. Without a validator
429
- only the top level of the body is compared — required fields, and fields
430
- that are a `const`, which is where an envelope keeps its code. `validate`
431
- checks the whole body with the JSON Schema validator of your choice, given
432
- a schema that stands on its own; `headers` names headers the status must
433
- declare when the response carries them:
434
-
435
- ```ts
436
- await assertDescribed(document, "POST /session", res, {
437
- validate: (schema, body) => ajv.validate(schema, body) || ajv.errorsText(),
438
- headers: ["retry-after"],
439
- });
440
- ```
441
-
442
- The core does not check a thrown error against the route's response map:
443
- a guard's refusal would be reported on every route it runs on. A test asks
444
- about the one response it provoked.
445
-
446
- ## Warnings
447
-
448
- A route is still documented when part of it cannot be described, and the
449
- gap is reported instead of failing the startup:
450
-
451
- ```
452
- [openapi] POST /api/users: the body schema does not emit JSON Schema
453
- ```
22
+ - [tetsujs.com/docs/packages/openapi](https://tetsujs.com/docs/packages/openapi/): options, details
23
+ and recipes, also [as Markdown](https://tetsujs.com/docs/packages/openapi.md)
24
+ - [llms-full.txt](https://tetsujs.com/llms-full.txt): the whole documentation in one
25
+ file, for tools and AI assistants
26
+ - every export and option is also documented in the package's type
27
+ definitions, which editors and tools read from `node_modules`
454
28
 
455
- This happens with a validator that does not emit JSON Schema, a schema
456
- whose conversion throws, and two routes that map to the same OpenAPI path
457
- (`/files/*` and `/files/:wildcard` are both `/files/{wildcard}`).
29
+ ## License
458
30
 
459
- It also happens with a recursive or named schema. A schema is embedded as
460
- the validator emits it, and the references inside it — `#` for its own
461
- root, `#/$defs/…` for a definition next to it — resolve against the
462
- document's root once embedded, where they lead nowhere. Zod's recursive
463
- getters and `.meta({ id })`, ArkType's scopes and Valibot's `lazy` emit
464
- them; TypeBox's `Type.Cyclic` names itself with `$id` and is described as
465
- it is.
31
+ [MIT](https://github.com/tetsujs/tetsu/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tetsujs/openapi",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "An OpenAPI 3.1 document and docs page generated from Tetsu routes",
5
5
  "keywords": [
6
6
  "bun",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "author": "tetsuodev",
13
13
  "license": "MIT",
14
- "homepage": "https://github.com/tetsujs/tetsu/tree/main/packages/openapi#readme",
14
+ "homepage": "https://tetsujs.com/docs/packages/openapi/",
15
15
  "bugs": {
16
16
  "url": "https://github.com/tetsujs/tetsu/issues"
17
17
  },
@@ -38,10 +38,10 @@
38
38
  }
39
39
  },
40
40
  "peerDependencies": {
41
- "@tetsujs/core": "^0.6.0"
41
+ "@tetsujs/core": "^0.6.2"
42
42
  },
43
43
  "devDependencies": {
44
- "@tetsujs/core": "0.6.0"
44
+ "@tetsujs/core": "0.6.2"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"