@tetsujs/openapi 0.5.1 → 0.5.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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/testing.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Checking a response against the document, in a test.\n *\n * The document describes what every operation can answer; a test provokes\n * one answer. `assertDescribed` holds the two together: the status is\n * declared, the body is one the status describes, a header asked about is\n * documented. What it catches is what a client generated from the document\n * would trip over — an error code nobody declared, a field the schema says\n * is always there and is not.\n *\n * It checks only what a test provoked, which is why it can be strict: the\n * core does not compare a thrown error with the route's response map,\n * because a guard's refusal would be reported on every route it runs on;\n * a test that provoked the refusal asks about that one response.\n *\n * ```ts\n * import { assertDescribed } from \"@tetsujs/openapi/testing\";\n *\n * const { document } = openapi(app, { info });\n *\n * test(\"a wrong code is what the document says\", async () => {\n * const res = await request(\"/session\", { method: \"POST\", body });\n *\n * await assertDescribed(document, \"POST /session\", res);\n * });\n * ```\n *\n * @module\n */\n\nimport type { OpenApiDocument, ResponseObject } from \"./document.ts\";\n\n/** What `assertDescribed` checks besides the status and the body's shape. */\nexport interface AssertDescribedOptions {\n /**\n * Checks the body against its schema in full, with the JSON Schema\n * validator of your choice — this package has none, and without one\n * only the top level is compared: required fields, fields that are a\n * `const`. Return `true` when the body is valid, or what is wrong.\n *\n * The schema stands on its own: every `$ref` into the document's\n * components points into its own `$defs`.\n *\n * @example\n * ```ts\n * const ajv = new Ajv2020();\n *\n * await assertDescribed(document, \"POST /session\", res, {\n * validate: (schema, body) => ajv.validate(schema, body) || ajv.errorsText(),\n * });\n * ```\n */\n readonly validate?: (\n schema: Record<string, unknown>,\n body: unknown,\n ) => true | string;\n\n /**\n * Headers the status must declare when the response carries them —\n * `retry-after`. Only those named: a response carries headers no\n * document describes, `content-type` and `x-request-id` among them.\n */\n readonly headers?: readonly string[];\n}\n\n/**\n * Throws unless the response is one the document describes for the\n * operation, listing every way it is not.\n *\n * `request` names the operation as the test called it — a method and the\n * path it requested, `\"GET /users/42\"` — matched against the document's\n * path templates. The body is read from a clone, and the test can still\n * read the response.\n */\nexport async function assertDescribed(\n document: OpenApiDocument,\n request: string,\n response: Response,\n options: AssertDescribedOptions = {},\n): Promise<void> {\n const problems = await problemsOf(document, request, response, options);\n\n if (problems.length > 0) {\n throw new Error(\n `${request} answered ${response.status}, which the document does not describe:\\n${problems.map((problem) => `- ${problem}`).join(\"\\n\")}`,\n );\n }\n}\n\nasync function problemsOf(\n document: OpenApiDocument,\n request: string,\n response: Response,\n options: AssertDescribedOptions,\n): Promise<string[]> {\n const [method = \"\", target = \"\"] = request.split(\" \");\n const path = target.split(\"?\")[0] ?? \"\";\n const template = templateOf(document, path);\n const operation =\n template === undefined\n ? undefined\n : document.paths[template]?.[method.toLowerCase()];\n\n if (operation === undefined) {\n return [`there is no operation for ${method.toUpperCase()} ${path}`];\n }\n\n const status = String(response.status);\n const described = operation.responses[status];\n\n if (described === undefined) {\n return [\n `${status}, which the operation does not declare: it declares ${Object.keys(operation.responses).join(\", \")}`,\n ];\n }\n\n return [\n ...(await bodyProblems(document, status, described, response, options)),\n ...headerProblems(status, described, response, options.headers ?? []),\n ];\n}\n\n/**\n * The path template a requested path belongs to: the one whose segments\n * match it, `{param}` matching any one segment. Of several, the one with\n * the most literal segments, as the router prefers `/users/me` to\n * `/users/{id}`; a template ending in a parameter also stands for a\n * wildcard, which matches the rest of the path.\n */\nfunction templateOf(\n document: OpenApiDocument,\n path: string,\n): string | undefined {\n const segments = path.split(\"/\").filter(Boolean);\n\n let best: { template: string; literals: number } | undefined;\n\n for (const template of Object.keys(document.paths)) {\n const parts = template.split(\"/\").filter(Boolean);\n const last = parts.at(-1) ?? \"\";\n const rest = isParameter(last) && segments.length > parts.length;\n\n if (parts.length !== segments.length && !rest) {\n continue;\n }\n\n let literals = 0;\n let matches = true;\n\n for (const [index, part] of parts.entries()) {\n if (isParameter(part)) {\n continue;\n }\n\n if (part !== segments[index]) {\n matches = false;\n\n break;\n }\n\n literals += 1;\n }\n\n if (matches && (best === undefined || literals > best.literals)) {\n best = { template, literals };\n }\n }\n\n return best?.template;\n}\n\nfunction isParameter(segment: string): boolean {\n return segment.startsWith(\"{\") && segment.endsWith(\"}\");\n}\n\nasync function bodyProblems(\n document: OpenApiDocument,\n status: string,\n described: ResponseObject,\n response: Response,\n options: AssertDescribedOptions,\n): Promise<string[]> {\n const text = await response.clone().text();\n const content = described.content ?? {};\n const mediaTypes = Object.keys(content);\n\n if (text === \"\") {\n return [];\n }\n\n if (mediaTypes.length === 0) {\n return [`a body, where its ${status} describes none`];\n }\n\n const mediaType = (response.headers.get(\"content-type\") ?? \"\")\n .split(\";\")[0]\n ?.trim();\n\n if (mediaType === undefined || !mediaTypes.includes(mediaType)) {\n return [\n `a body of type \"${mediaType ?? \"\"}\", where its ${status} describes ${mediaTypes.join(\", \")}`,\n ];\n }\n\n const schema = content[mediaType]?.schema;\n\n if (schema === undefined || !mediaType.endsWith(\"json\")) {\n return [];\n }\n\n let body: unknown;\n\n try {\n body = JSON.parse(text);\n } catch {\n return [`a body that is not the JSON its ${status} describes`];\n }\n\n const problems = shapeProblems(document, status, schema, body);\n\n if (options.validate) {\n const verdict = options.validate(standalone(document, schema), body);\n\n if (verdict !== true) {\n problems.push(verdict);\n }\n }\n\n return problems;\n}\n\n/**\n * What the top level of a body gets wrong against every alternative its\n * status describes — nothing, when one of them fits. Named by the field\n * that tells the alternatives apart, when they have one: the code of an\n * envelope, compared with the codes the status lists.\n */\nfunction shapeProblems(\n document: OpenApiDocument,\n status: string,\n schema: Record<string, unknown>,\n body: unknown,\n): string[] {\n const alternatives = alternativesOf(document, schema);\n const misfits = alternatives.map((alternative) =>\n misfitOf(alternative, body),\n );\n\n if (misfits.some((misfit) => misfit === undefined)) {\n return [];\n }\n\n const field = tellingField(alternatives);\n\n if (field !== undefined && isRecord(body) && field in body) {\n const listed: unknown[] = alternatives\n .map((alternative) => constOf(propertiesOf(alternative)[field]))\n .filter((value) => value !== undefined);\n\n if (!listed.includes(body[field])) {\n return [\n `${field} ${JSON.stringify(body[field])}, which its ${status} does not list: ${listed.map(String).join(\", \")}`,\n ];\n }\n }\n\n const reasons = [...new Set(misfits)];\n\n return [\n reasons.length === 1\n ? `a body that ${reasons[0]}`\n : `a body that fits none of the ${alternatives.length} its ${status} describes: ${reasons.join(\"; \")}`,\n ];\n}\n\n/** The alternatives a schema stands for, each `$ref` followed. */\nfunction alternativesOf(\n document: OpenApiDocument,\n schema: SchemaView,\n): SchemaView[] {\n const resolved = resolve(document, schema);\n const union = resolved.anyOf ?? resolved.oneOf;\n\n if (Array.isArray(union)) {\n return union.flatMap((branch) =>\n isRecord(branch) ? alternativesOf(document, branch) : [],\n );\n }\n\n return [resolved];\n}\n\nfunction resolve(document: OpenApiDocument, schema: SchemaView): SchemaView {\n const ref = schema.$ref;\n\n if (typeof ref !== \"string\" || !ref.startsWith(\"#/components/schemas/\")) {\n return schema;\n }\n\n const target =\n document.components?.schemas?.[ref.slice(\"#/components/schemas/\".length)];\n\n return isRecord(target) ? target : schema;\n}\n\n/**\n * How a body fails one alternative at the top level, or `undefined` when\n * it does not: a required field missing, a `const` field different.\n */\nfunction misfitOf(alternative: SchemaView, body: unknown): string | undefined {\n const properties = propertiesOf(alternative);\n const required = Array.isArray(alternative.required)\n ? (alternative.required as string[])\n : [];\n\n if (alternative.type === \"object\" || required.length > 0) {\n if (!isRecord(body)) {\n return \"is not an object\";\n }\n\n const missing = required.filter((name) => !(name in body));\n\n if (missing.length > 0) {\n return `lacks ${missing.join(\", \")}`;\n }\n\n for (const [name, property] of Object.entries(properties)) {\n const expected = constOf(property);\n\n if (expected !== undefined && name in body && body[name] !== expected) {\n return `has ${name} ${JSON.stringify(body[name])} where it is ${JSON.stringify(expected)}`;\n }\n }\n }\n\n return undefined;\n}\n\n/**\n * The field whose `const` differs from one alternative to the next — the\n * code of an envelope — or none.\n */\nfunction tellingField(alternatives: readonly SchemaView[]): string | undefined {\n const [first] = alternatives;\n\n if (first === undefined) {\n return undefined;\n }\n\n return Object.keys(propertiesOf(first)).find(\n (name) =>\n name !== \"status\" &&\n alternatives.every(\n (alternative) =>\n typeof constOf(propertiesOf(alternative)[name]) === \"string\",\n ),\n );\n}\n\nfunction propertiesOf(schema: SchemaView): Record<string, unknown> {\n return isRecord(schema.properties) ? schema.properties : {};\n}\n\nfunction constOf(value: unknown): unknown {\n if (!isRecord(value)) {\n return undefined;\n }\n\n const property: SchemaView = value;\n\n if (\"const\" in property) {\n return property.const;\n }\n\n return Array.isArray(property.enum) && property.enum.length === 1\n ? property.enum[0]\n : undefined;\n}\n\n/**\n * A schema that needs nothing but itself: the document's components as its\n * `$defs`, and every reference into them pointed there — what a validator\n * that knows JSON Schema and not OpenAPI can compile.\n */\nfunction standalone(\n document: OpenApiDocument,\n schema: Record<string, unknown>,\n): Record<string, unknown> {\n const repoint = (value: unknown): unknown => {\n if (Array.isArray(value)) {\n return value.map(repoint);\n }\n\n if (!isRecord(value)) {\n return value;\n }\n\n return Object.fromEntries(\n Object.entries(value).map(([key, inner]) => [\n key,\n key === \"$ref\" && typeof inner === \"string\"\n ? inner.replace(\"#/components/schemas/\", \"#/$defs/\")\n : repoint(inner),\n ]),\n );\n };\n\n return {\n ...(repoint(schema) as Record<string, unknown>),\n $defs: repoint(document.components?.schemas ?? {}),\n };\n}\n\nfunction headerProblems(\n status: string,\n described: ResponseObject,\n response: Response,\n names: readonly string[],\n): string[] {\n const declared = Object.keys(described.headers ?? {}).map((name) =>\n name.toLowerCase(),\n );\n\n return names\n .filter(\n (name) =>\n response.headers.has(name) && !declared.includes(name.toLowerCase()),\n )\n .map(\n (name) => `the header \"${name}\", which its ${status} does not declare`,\n );\n}\n\n/**\n * A schema as this module reads it: any keyword, with the ones it looks\n * into named, so reading them does not depend on the reader's\n * `noPropertyAccessFromIndexSignature`.\n */\ninterface SchemaView {\n readonly $ref?: unknown;\n readonly anyOf?: unknown;\n readonly oneOf?: unknown;\n readonly type?: unknown;\n readonly required?: unknown;\n readonly properties?: unknown;\n readonly const?: unknown;\n readonly enum?: unknown;\n readonly [keyword: string]: unknown;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "608BF03DC34E084D64756E2164756E21",
9
+ "names": []
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tetsujs/openapi",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "An OpenAPI 3.1 document and docs page generated from Tetsu routes",
5
5
  "keywords": [
6
6
  "bun",
@@ -31,13 +31,17 @@
31
31
  ".": {
32
32
  "types": "./dist/src/index.d.ts",
33
33
  "default": "./dist/src/index.js"
34
+ },
35
+ "./testing": {
36
+ "types": "./dist/src/testing.d.ts",
37
+ "default": "./dist/src/testing.js"
34
38
  }
35
39
  },
36
40
  "peerDependencies": {
37
- "@tetsujs/core": "^0.5.1"
41
+ "@tetsujs/core": "^0.5.2"
38
42
  },
39
43
  "devDependencies": {
40
- "@tetsujs/core": "0.5.1"
44
+ "@tetsujs/core": "0.5.2"
41
45
  },
42
46
  "publishConfig": {
43
47
  "access": "public"
package/src/controller.ts CHANGED
@@ -40,6 +40,13 @@ export interface DocsOptions<
40
40
  */
41
41
  readonly errors?: ErrorFormat;
42
42
 
43
+ /**
44
+ * What each tag is — see `tags` on {@link openapi}. The controller's own
45
+ * tag, `docs`, is described for you when its routes are in the document
46
+ * (`documentSelf`) and you did not describe it.
47
+ */
48
+ readonly tags?: Readonly<Record<string, string>>;
49
+
43
50
  /** Where the document is served. Defaults to `/openapi.json`. */
44
51
  readonly path?: Path & ValidatePath<Path>;
45
52
 
@@ -144,6 +151,21 @@ export class DocsController<
144
151
  ) as this["ui"];
145
152
  }
146
153
 
154
+ /**
155
+ * The tags to describe: the application's, and this controller's own when
156
+ * its routes are in the document — without it, a described set of tags
157
+ * would report `docs` as a tag nobody described.
158
+ */
159
+ private tagsOf(
160
+ tags: Readonly<Record<string, string>>,
161
+ ): Readonly<Record<string, string>> {
162
+ if (!this.options.documentSelf || Object.hasOwn(tags, "docs")) {
163
+ return tags;
164
+ }
165
+
166
+ return { ...tags, docs: "This documentation" };
167
+ }
168
+
147
169
  /**
148
170
  * Generates the document of the application this was mounted in, and
149
171
  * the page that renders it.
@@ -158,6 +180,7 @@ export class DocsController<
158
180
  info: this.options.info,
159
181
  ...(this.options.servers ? { servers: this.options.servers } : {}),
160
182
  ...(this.options.errors ? { errors: this.options.errors } : {}),
183
+ ...(this.options.tags ? { tags: this.tagsOf(this.options.tags) } : {}),
161
184
  });
162
185
 
163
186
  this.document = generated.document;
@@ -218,5 +241,9 @@ export function docs<
218
241
  }
219
242
 
220
243
  function warn(warning: GeneratorWarning): void {
221
- console.warn(`[openapi] ${warning.route}: ${warning.message}`);
244
+ console.warn(
245
+ warning.route === ""
246
+ ? `[openapi] ${warning.message}`
247
+ : `[openapi] ${warning.route}: ${warning.message}`,
248
+ );
222
249
  }
package/src/document.ts CHANGED
@@ -78,6 +78,19 @@ export interface OperationObject {
78
78
  /** Every operation declared on one path template. */
79
79
  export type PathItemObject = Record<string, OperationObject>;
80
80
 
81
+ /**
82
+ * A tag the document describes: its name and what its operations are.
83
+ *
84
+ * Open to the rest of the specification's fields — `externalDocs` — as
85
+ * `info` is: the generator fills in two, and a document a caller builds
86
+ * or extends by hand may carry more.
87
+ */
88
+ export interface TagObject {
89
+ readonly name: string;
90
+ readonly description?: string;
91
+ readonly [key: string]: unknown;
92
+ }
93
+
81
94
  /** The generated document. */
82
95
  export interface OpenApiDocument {
83
96
  readonly openapi: "3.1.0";
@@ -85,6 +98,12 @@ export interface OpenApiDocument {
85
98
  readonly servers?: readonly DocumentServer[];
86
99
  readonly paths: Record<string, PathItemObject>;
87
100
 
101
+ /**
102
+ * The tags the operations are grouped under, in the order a renderer
103
+ * lists them. Present only when the generator was given descriptions.
104
+ */
105
+ readonly tags?: readonly TagObject[];
106
+
88
107
  /**
89
108
  * What the operations refer to rather than repeat: the security schemes
90
109
  * the guards registered, and the failure envelopes the framework fills
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  import type { App } from "@tetsujs/core";
23
23
  import { contributionsOf } from "./annotations.ts";
24
24
  import { schemaComponents } from "./components.ts";
25
- import type { OpenApiDocument, PathItemObject } from "./document.ts";
25
+ import type { OpenApiDocument, PathItemObject, TagObject } from "./document.ts";
26
26
  import { envelopes as envelopeDefinitions } from "./envelopes.ts";
27
27
  import type { ErrorFormat } from "./errors.ts";
28
28
  import { resolveFormat } from "./errors.ts";
@@ -51,6 +51,7 @@ export type {
51
51
  ParameterObject,
52
52
  PathItemObject,
53
53
  ResponseObject,
54
+ TagObject,
54
55
  } from "./document.ts";
55
56
  export type { DocumentedFailure, ErrorFormat } from "./errors.ts";
56
57
  export type {
@@ -97,6 +98,29 @@ export interface OpenApiOptions {
97
98
  * ```
98
99
  */
99
100
  readonly errors?: ErrorFormat;
101
+
102
+ /**
103
+ * What each tag is, by name — the sections a renderer's sidebar shows,
104
+ * in the order given here.
105
+ *
106
+ * A tag the routes use and this leaves out is listed after these, in the
107
+ * order the routes first use it, without a description; so is reported,
108
+ * as a likely misspelling — `sign_in` for `sign-in` makes a section of its
109
+ * own. So is a tag described here that no operation uses. Without
110
+ * `tags`, the document lists none and nothing is reported.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * openapi(app, {
115
+ * info,
116
+ * tags: {
117
+ * "sign-in": "Signing in with a code sent by email, and signing out",
118
+ * me: "The signed-in user",
119
+ * },
120
+ * });
121
+ * ```
122
+ */
123
+ readonly tags?: Readonly<Record<string, string>>;
100
124
  }
101
125
 
102
126
  /**
@@ -108,6 +132,10 @@ export interface OpenApiOptions {
108
132
  * these are returned rather than swallowed.
109
133
  */
110
134
  export interface GeneratorWarning {
135
+ /**
136
+ * The route the warning is about, as `GET /path` — empty for a warning
137
+ * about the document as a whole, such as a described tag nothing uses.
138
+ */
111
139
  readonly route: string;
112
140
  readonly message: string;
113
141
  }
@@ -163,6 +191,10 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
163
191
  // that works. It still loses it; it no longer does so without saying.
164
192
  const occupied = new Map<string, string>();
165
193
 
194
+ // Every tag the operations use, with the first route using it: the
195
+ // order undescribed tags are listed in, and where each is reported.
196
+ const used = new Map<string, string>();
197
+
166
198
  for (const entry of described) {
167
199
  const template = toTemplate(entry.path);
168
200
  const route = `${entry.method} ${entry.path}`;
@@ -214,17 +246,26 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
214
246
 
215
247
  occupied.set(slot, route);
216
248
 
249
+ for (const tag of operation.tags ?? []) {
250
+ if (!used.has(tag)) {
251
+ used.set(tag, route);
252
+ }
253
+ }
254
+
217
255
  paths[template] = {
218
256
  ...paths[template],
219
257
  [entry.method.toLowerCase()]: operation,
220
258
  };
221
259
  }
222
260
 
261
+ const tags = options.tags ? tagsOf(options.tags, used, warnings) : undefined;
262
+
223
263
  return {
224
264
  document: {
225
265
  openapi: "3.1.0",
226
266
  info: options.info,
227
267
  ...(options.servers ? { servers: options.servers } : {}),
268
+ ...(tags ? { tags } : {}),
228
269
  paths,
229
270
  ...componentsOf(schemes, components.schemas),
230
271
  },
@@ -232,6 +273,49 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
232
273
  };
233
274
  }
234
275
 
276
+ /**
277
+ * The document's tags: the described ones in the order given, then those
278
+ * the operations use and the description leaves out, in the order they are
279
+ * first used — so the order of the sections is the application's, not
280
+ * whatever a renderer does with a tag it was not told about.
281
+ *
282
+ * Both kinds of mismatch are reported, since either is usually one tag
283
+ * spelled two ways: a tag used and not described, at the first route
284
+ * using it, and a tag described and not used, for the document.
285
+ */
286
+ function tagsOf(
287
+ described: Readonly<Record<string, string>>,
288
+ used: ReadonlyMap<string, string>,
289
+ warnings: GeneratorWarning[],
290
+ ): TagObject[] {
291
+ const tags: TagObject[] = Object.entries(described).map(
292
+ ([name, description]) => ({ name, description }),
293
+ );
294
+
295
+ for (const [name, route] of used) {
296
+ if (Object.hasOwn(described, name)) {
297
+ continue;
298
+ }
299
+
300
+ tags.push({ name });
301
+ warnings.push({
302
+ route,
303
+ message: `the tag "${name}" is not in tags; it is listed after them, without a description`,
304
+ });
305
+ }
306
+
307
+ for (const name of Object.keys(described)) {
308
+ if (!used.has(name)) {
309
+ warnings.push({
310
+ route: "",
311
+ message: `the tag "${name}" is described in tags, but no operation uses it`,
312
+ });
313
+ }
314
+ }
315
+
316
+ return tags;
317
+ }
318
+
235
319
  /**
236
320
  * The `components` block, present only when something goes in it: the
237
321
  * security schemes the guards registered, and the failure envelopes the