@tetsujs/openapi 0.6.1 → 0.6.3

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,30 @@
1
+ /**
2
+ * Media types, as the document's `content` keys them.
3
+ *
4
+ * The core checks a route's `contentType` when the route is declared; a
5
+ * response annotated with `documented()` reaches the document without the
6
+ * core seeing it, so the same rule is applied here, when it is annotated.
7
+ *
8
+ * @module
9
+ */
10
+ /**
11
+ * Whether a value is a media type or a range of them, as a document's
12
+ * `content` names it: `text/csv`, `image/*`, the range of every type — and
13
+ * no parameters, which a key would carry into every comparison with what a
14
+ * response says.
15
+ */
16
+ export declare function isMediaType(value: unknown): value is string;
17
+ /**
18
+ * Whether a media type is the one a body is described as by default, and
19
+ * the framework sends a returned value with. Compared without regard to
20
+ * case, as media types are.
21
+ */
22
+ export declare function isJson(type: string): boolean;
23
+ /**
24
+ * Whether a body under this key is JSON to parse: `application/json`, or
25
+ * a type with the `+json` suffix. A range is not — its schema describes
26
+ * the bytes of whatever type answers — and neither is `x-ndjson`, which
27
+ * is lines of JSON, not one value.
28
+ */
29
+ export declare function parsesAsJson(type: string): boolean;
30
+ //# sourceMappingURL=media.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/media.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAgB3D;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE5C;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAIlD"}
@@ -17,10 +17,13 @@ import type { Envelopes } from "./envelopes.ts";
17
17
  export declare function operationOf(entry: RouteTableEntry, options: AppOptions, components: SchemaComponents, envelopes: Envelopes, ids: OperationIds, warn: (message: string) => void): OperationObject;
18
18
  /**
19
19
  * Records the envelopes a route declares, so that its definitions are the
20
- * ones the document keeps — whichever operation refers to them first.
20
+ * ones the document keeps — whichever operation refers to them first. What
21
+ * its handler was annotated with is the route's own too, and declares the
22
+ * same way.
21
23
  *
22
24
  * Runs before any operation is built. A schema that cannot describe itself
23
- * is skipped without a word: building the operation says so, once.
25
+ * is skipped without a word: building the operation says so, once. A body
26
+ * of another type than JSON is no envelope.
24
27
  */
25
28
  export declare function declareEnvelopes(entry: RouteTableEntry, envelopes: Envelopes): void;
26
29
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"operation.d.ts","sourceRoot":"","sources":["../../src/operation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAEV,UAAU,EAGV,eAAe,EAChB,MAAM,eAAe,CAAC;AAQvB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD,OAAO,KAAK,EAGV,eAAe,EAEhB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAKhD,2BAA2B;AAC3B,wBAAgB,WAAW,CACzB,KAAK,EAAE,eAAe,EACtB,OAAO,EAAE,UAAU,EACnB,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,SAAS,EACpB,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAC9B,eAAe,CAyCjB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,SAAS,GACnB,IAAI,CAcN;AA2OD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC;CACtC;AAED,wBAAgB,YAAY,IAAI,YAAY,CAmB3C"}
1
+ {"version":3,"file":"operation.d.ts","sourceRoot":"","sources":["../../src/operation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAEV,UAAU,EAGV,eAAe,EAChB,MAAM,eAAe,CAAC;AAQvB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD,OAAO,KAAK,EAGV,eAAe,EAEhB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAMhD,2BAA2B;AAC3B,wBAAgB,WAAW,CACzB,KAAK,EAAE,eAAe,EACtB,OAAO,EAAE,UAAU,EACnB,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,SAAS,EACpB,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAC9B,eAAe,CAyCjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,SAAS,GACnB,IAAI,CA8BN;AA+OD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC;CACtC;AAED,wBAAgB,YAAY,IAAI,YAAY,CAmB3C"}
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,eAAe,CAAC;AAErE,6EAA6E;AAC7E,MAAM,WAAW,sBAAsB;IACrC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,OAAO,KACV,IAAI,GAAG,MAAM,CAAC;IAEnB;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,eAAe,EACzB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,IAAI,CAAC,CAQf"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,eAAe,CAAC;AAGrE,6EAA6E;AAC7E,MAAM,WAAW,sBAAsB;IACrC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,OAAO,KACV,IAAI,GAAG,MAAM,CAAC;IAEnB;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,eAAe,EACzB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,IAAI,CAAC,CAQf"}
@@ -1,4 +1,8 @@
1
1
  // @bun
2
+ import {
3
+ parsesAsJson
4
+ } from "../index-f49v8ege.js";
5
+
2
6
  // packages/openapi/src/testing.ts
3
7
  async function assertDescribed(document, request, response, options = {}) {
4
8
  const problems = await problemsOf(document, request, response, options);
@@ -59,26 +63,38 @@ function templateOf(document, path) {
59
63
  function isParameter(segment) {
60
64
  return segment.startsWith("{") && segment.endsWith("}");
61
65
  }
66
+ function keyFor(described, mediaType) {
67
+ const [type] = mediaType.split("/");
68
+ for (const wanted of [mediaType, `${type}/*`, "*/*"]) {
69
+ const key = described.find((each) => each.toLowerCase() === wanted);
70
+ if (key !== undefined) {
71
+ return key;
72
+ }
73
+ }
74
+ return;
75
+ }
62
76
  async function bodyProblems(document, status, described, response, options) {
63
77
  const text = await response.clone().text();
64
78
  const content = described.content ?? {};
65
79
  const mediaTypes = Object.keys(content);
80
+ const mediaType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
81
+ const key = mediaType === "" ? undefined : keyFor(mediaTypes, mediaType);
66
82
  if (text === "") {
67
- return mediaTypes.length === 0 ? [] : [
83
+ const empty = mediaTypes.length === 0 || key !== undefined && !parsesAsJson(key) || mediaType === "" && mediaTypes.some((each) => !parsesAsJson(each));
84
+ return empty ? [] : [
68
85
  `an empty body, where its ${status} describes ${mediaTypes.join(", ")}`
69
86
  ];
70
87
  }
71
88
  if (mediaTypes.length === 0) {
72
89
  return [`a body, where its ${status} describes none`];
73
90
  }
74
- const mediaType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim();
75
- if (mediaType === undefined || !mediaTypes.includes(mediaType)) {
91
+ if (key === undefined) {
76
92
  return [
77
- `a body of type "${mediaType ?? ""}", where its ${status} describes ${mediaTypes.join(", ")}`
93
+ `a body of type "${mediaType}", where its ${status} describes ${mediaTypes.join(", ")}`
78
94
  ];
79
95
  }
80
- const schema = content[mediaType]?.schema;
81
- if (schema === undefined || !mediaType.endsWith("json")) {
96
+ const schema = content[key]?.schema;
97
+ if (schema === undefined || !parsesAsJson(key)) {
82
98
  return [];
83
99
  }
84
100
  let body;
@@ -203,5 +219,5 @@ export {
203
219
  assertDescribed
204
220
  };
205
221
 
206
- //# debugId=7B8B75FA0C83D4F464756E2164756E21
222
+ //# debugId=CF216AE6A46C36AE64756E2164756E21
207
223
  //# sourceMappingURL=testing.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/testing.ts"],
4
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 * The other way needs no list: a header the status declares required —\n * a `location`, a `set-cookie` — is one the response must carry.\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 `{wildcard}` — the name the\n * generator gives a trailing `*` — matches the rest of the path; any other\n * matches as many segments as it has, so a mistyped path is no operation\n * rather than the one it happens to start like.\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 = last === \"{wildcard}\" && 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 mediaTypes.length === 0\n ? []\n : [\n `an empty body, where its ${status} describes ${mediaTypes.join(\", \")}`,\n ];\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 const undeclared = 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 const missing = Object.entries(described.headers ?? {})\n .filter(([name, header]) => header.required && !response.headers.has(name))\n .map(([name]) => `no header \"${name}\", which its ${status} requires`);\n\n return [...undeclared, ...missing];\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"
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\";\nimport { parsesAsJson } from \"./media.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 * The other way needs no list: a header the status declares required —\n * a `location`, a `set-cookie` — is one the response must carry.\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 `{wildcard}` — the name the\n * generator gives a trailing `*` — matches the rest of the path; any other\n * matches as many segments as it has, so a mistyped path is no operation\n * rather than the one it happens to start like.\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 = last === \"{wildcard}\" && 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\n/**\n * The media type of the document that describes a response's body: its\n * own, then the range of its type, then the range of every type — the\n * order in which OpenAPI lets the more specific one win. Compared without\n * regard to case, as media types are.\n */\nfunction keyFor(\n described: readonly string[],\n mediaType: string,\n): string | undefined {\n const [type] = mediaType.split(\"/\");\n\n for (const wanted of [mediaType, `${type}/*`, \"*/*\"]) {\n const key = described.find((each) => each.toLowerCase() === wanted);\n\n if (key !== undefined) {\n return key;\n }\n }\n\n return undefined;\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 const mediaType =\n (response.headers.get(\"content-type\") ?? \"\")\n .split(\";\")[0]\n ?.trim()\n .toLowerCase() ?? \"\";\n\n const key = mediaType === \"\" ? undefined : keyFor(mediaTypes, mediaType);\n\n /**\n * An empty export, a file of no bytes, a stream that yielded nothing:\n * empty is content of every type but JSON, which has no empty value. An\n * empty body may carry no type at all, and still be one a status of\n * another type than JSON describes.\n */\n if (text === \"\") {\n const empty =\n mediaTypes.length === 0 ||\n (key !== undefined && !parsesAsJson(key)) ||\n (mediaType === \"\" && mediaTypes.some((each) => !parsesAsJson(each)));\n\n return empty\n ? []\n : [\n `an empty body, where its ${status} describes ${mediaTypes.join(\", \")}`,\n ];\n }\n\n if (mediaTypes.length === 0) {\n return [`a body, where its ${status} describes none`];\n }\n\n if (key === undefined) {\n return [\n `a body of type \"${mediaType}\", where its ${status} describes ${mediaTypes.join(\", \")}`,\n ];\n }\n\n const schema = content[key]?.schema;\n\n if (schema === undefined || !parsesAsJson(key)) {\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 const undeclared = 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 const missing = Object.entries(described.headers ?? {})\n .filter(([name, header]) => header.required && !response.headers.has(name))\n .map(([name]) => `no header \"${name}\", which its ${status} requires`);\n\n return [...undeclared, ...missing];\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
6
  ],
7
- "mappings": "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",
8
- "debugId": "7B8B75FA0C83D4F464756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "CF216AE6A46C36AE64756E2164756E21",
9
9
  "names": []
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tetsujs/openapi",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "An OpenAPI 3.1 document and docs page generated from Tetsu routes",
5
5
  "keywords": [
6
6
  "bun",
@@ -38,10 +38,10 @@
38
38
  }
39
39
  },
40
40
  "peerDependencies": {
41
- "@tetsujs/core": "^0.6.1"
41
+ "@tetsujs/core": "^0.6.3"
42
42
  },
43
43
  "devDependencies": {
44
- "@tetsujs/core": "0.6.1"
44
+ "@tetsujs/core": "0.6.3"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"
@@ -1,12 +1,14 @@
1
1
  /**
2
- * What hooks contribute to the documentation.
2
+ * What hooks, and the handlers packages hand out, contribute to the
3
+ * documentation.
3
4
  *
4
5
  * What a route is protected by, or answers with when a guard refuses, is
5
6
  * not in its config: a hook is a function in a slot, and the route that
6
7
  * mounts it says nothing about tokens or rate limits. The contribution is
7
8
  * therefore annotated onto the hook — from this package, not from
8
9
  * `hook.*` — so the core keeps knowing nothing about OpenAPI and the
9
- * factories stay free of documentation options.
10
+ * factories stay free of documentation options. A package's handler is
11
+ * annotated the same way, for the routes that mount it.
10
12
  *
11
13
  * A hook annotated once is documented everywhere it runs: application and
12
14
  * group chains are merged into every route's chains at startup, so a guard
@@ -18,6 +20,7 @@
18
20
 
19
21
  import type { AnyHook, AnySchema } from "@tetsujs/core";
20
22
  import type { JsonSchema } from "./json-schema.ts";
23
+ import { isMediaType } from "./media.ts";
21
24
 
22
25
  /**
23
26
  * The definition of a scheme, as OpenAPI spells it.
@@ -63,15 +66,37 @@ export interface SecurityRequirement {
63
66
 
64
67
  /**
65
68
  * One response a hook can produce, which the route's own schema knows
66
- * nothing about — a rate limiter's `429`, a guard's `401`.
69
+ * nothing about — a rate limiter's `429`, a guard's `401` — or one a
70
+ * package's handler answers with, for the routes that mount it: a file,
71
+ * a `304`.
67
72
  */
68
73
  export interface DocumentedResponse {
69
74
  readonly status: number;
70
75
  readonly description: string;
71
76
 
72
- /** The body, when the hook answers with one. */
77
+ /**
78
+ * The body, when it is described: JSON, or the content under
79
+ * `contentType` when there is one.
80
+ *
81
+ * Without `schema` or `contentType`, an error status — `400` and above —
82
+ * answers with the framework's envelope, which `error`, `message` and
83
+ * `fields` describe, and any other status carries no body: a `304`, a
84
+ * redirect.
85
+ */
73
86
  readonly schema?: AnySchema;
74
87
 
88
+ /**
89
+ * The media type of the body when it is not JSON — `"text/html"`, or a
90
+ * range such as `"image/*"` or the range of every type for a file whose
91
+ * type is not known in advance. Without a `schema`, the body is
92
+ * described by its type alone.
93
+ *
94
+ * Two responses of one status with different types are both in the
95
+ * document, each under its own: the site's `404.html` next to the
96
+ * envelope an API client gets.
97
+ */
98
+ readonly contentType?: string;
99
+
75
100
  /**
76
101
  * The `error` code of the failure envelope, when the hook answers with
77
102
  * the framework's shape — `"RATE_LIMITED"`, `"SESSION_EXPIRED"`.
@@ -153,17 +178,65 @@ export interface HookDocs {
153
178
  readonly responses?: readonly DocumentedResponse[];
154
179
  }
155
180
 
181
+ /**
182
+ * Everything a handler tells the generator about the route it answers
183
+ * for.
184
+ *
185
+ * A handler a package hands out — a directory of files, say — knows what
186
+ * it answers with better than the route it is mounted on, whose author
187
+ * wrote one line: annotated once, it describes every route that mounts it.
188
+ */
189
+ export interface HandlerDocs {
190
+ /**
191
+ * Keeps the route out of the document unless the route says
192
+ * `docs: { hidden: false }`.
193
+ *
194
+ * For a handler whose routes are rarely anyone's API — the files of a
195
+ * site — and which a generated client could not call anyway. The route's
196
+ * own word wins either way: `docs: { hidden: true }` hides it whatever
197
+ * the handler says.
198
+ */
199
+ readonly hidden?: boolean;
200
+
201
+ /**
202
+ * What the handler answers with, as the route's own responses: a status
203
+ * the route's response map declares too is described by both, and a
204
+ * route that says nothing gets no placeholder `200` in their place.
205
+ */
206
+ readonly responses?: readonly DocumentedResponse[];
207
+ }
208
+
209
+ /** Any handler a route can take; the context is the route's to type. */
210
+ type AnyHandler = (ctx: never) => unknown;
211
+
156
212
  const docsKey = "~tetsu/hook-docs";
213
+ const handlerDocsKey = "~tetsu/handler-docs";
157
214
 
158
215
  /**
159
- * Annotates a hook with what it contributes to the documentation.
216
+ * Annotates a hook, or a handler, with what it contributes to the
217
+ * documentation.
160
218
  *
161
219
  * Returns a copy: what the caller exports is what carries the annotation,
162
- * and a hook used elsewhere is not changed behind its author's back. The
163
- * type is preserved exactly, so an annotated hook goes into a stack like
164
- * any other.
220
+ * and a hook or a handler used elsewhere is not changed behind its
221
+ * author's back. A handler's copy calls the handler it was made from. The
222
+ * type is preserved exactly, so an annotated hook goes into a stack, and an
223
+ * annotated handler onto a route, like any other.
165
224
  *
166
- * @example
225
+ * Annotating a handler is for one whose type is already settled — a
226
+ * package's, typed by its own context. An arrow written inside
227
+ * `documented()` on a route does not get the route's context, which
228
+ * `route()` would have given it, and does not compile; its statuses belong
229
+ * in the route's response map.
230
+ *
231
+ * One signature, for a hook and a handler alike, and one shape of what is
232
+ * said about either. An overload for each would keep `hidden` off a hook
233
+ * and `security` off a handler in the types, but TypeScript before 7
234
+ * reports a call no overload matches on the whole call, and a misspelled
235
+ * keyword deep in a response would no longer be pointed at. Those two are
236
+ * refused here instead, when `documented()` is called — as a module loads,
237
+ * before anything is served.
238
+ *
239
+ * @example A hook
167
240
  * ```ts
168
241
  * export const limiter = documented(hook.beforeParse(check), {
169
242
  * responses: [
@@ -171,8 +244,60 @@ const docsKey = "~tetsu/hook-docs";
171
244
  * ],
172
245
  * });
173
246
  * ```
247
+ *
248
+ * @example A package's handler, out of the document until a route asks
249
+ * ```ts
250
+ * return documented(serveFile, {
251
+ * hidden: true,
252
+ * responses: [
253
+ * { status: 200, description: "The file", contentType: "image/*" },
254
+ * { status: 304, description: "Not modified" },
255
+ * ],
256
+ * });
257
+ * ```
174
258
  */
175
- export function documented<H extends AnyHook>(hook: H, docs: HookDocs): H {
259
+ export function documented<T extends AnyHook | AnyHandler>(
260
+ target: T,
261
+ docs: HookDocs & HandlerDocs,
262
+ ): T {
263
+ const handled = typeof target === "function";
264
+
265
+ if (handled && docs.security !== undefined) {
266
+ throw new Error(
267
+ "documented() was given security for a handler — security is stated on the hook that enforces it, with secured()",
268
+ );
269
+ }
270
+
271
+ if (!handled && docs.hidden !== undefined) {
272
+ throw new Error(
273
+ "documented() was given hidden for a hook — a route is hidden by its own docs, or by its handler's annotation, not by a hook it mounts",
274
+ );
275
+ }
276
+
277
+ for (const response of docs.responses ?? []) {
278
+ if (
279
+ response.contentType !== undefined &&
280
+ !isMediaType(response.contentType)
281
+ ) {
282
+ throw new Error(
283
+ `The ${response.status} response given to documented() has the content type ${JSON.stringify(response.contentType)}, which is not a type such as text/html or a range such as image/* — the type alone, without parameters: the charset belongs on the response`,
284
+ );
285
+ }
286
+ }
287
+
288
+ if (handled) {
289
+ const handler = target as AnyHandler;
290
+ const copy: AnyHandler = (ctx) => handler(ctx);
291
+
292
+ Object.defineProperty(copy, handlerDocsKey, {
293
+ enumerable: false,
294
+ value: { ...handlerDocsOf(handler), ...docs },
295
+ });
296
+
297
+ return copy as T;
298
+ }
299
+
300
+ const hook = target as AnyHook;
176
301
  const annotated = { ...hook };
177
302
 
178
303
  Object.defineProperty(annotated, docsKey, {
@@ -180,7 +305,7 @@ export function documented<H extends AnyHook>(hook: H, docs: HookDocs): H {
180
305
  value: { ...docsOf(hook), ...docs },
181
306
  });
182
307
 
183
- return annotated as H;
308
+ return annotated as T;
184
309
  }
185
310
 
186
311
  /**
@@ -230,6 +355,15 @@ export function docsOf(hook: AnyHook): HookDocs | undefined {
230
355
  return (hook as unknown as Record<string, HookDocs | undefined>)[docsKey];
231
356
  }
232
357
 
358
+ /** Reads everything a handler was annotated with. */
359
+ export function handlerDocsOf(handler: unknown): HandlerDocs | undefined {
360
+ return typeof handler === "function"
361
+ ? (handler as unknown as Record<string, HandlerDocs | undefined>)[
362
+ handlerDocsKey
363
+ ]
364
+ : undefined;
365
+ }
366
+
233
367
  /** Reads the security a hook was annotated with, if it was. */
234
368
  export function securityOf(
235
369
  hook: AnyHook,
@@ -258,11 +392,12 @@ export interface HookContributions {
258
392
  /**
259
393
  * Collects the contributions of a route's chains.
260
394
  *
261
- * A scheme is registered once per name and a response once per status and
262
- * description: the same guard on the application and on a group is one
263
- * requirement, not two. Two hooks of one scheme asking for different
264
- * scopes both run, so the requirement asks for the scopes of both — each
265
- * once, in the order they were first asked for.
395
+ * A scheme is registered once per name: two hooks of one scheme asking for
396
+ * different scopes both run, so the requirement asks for the scopes of
397
+ * both — each once, in the order they were first asked for. Responses are
398
+ * kept as the hooks give them. Two guards may answer one status with one
399
+ * description and different codes, and both are what the route sends;
400
+ * answers that say the same are folded when the document is written.
266
401
  *
267
402
  * Deduplicating by name means two hooks claiming one name with different
268
403
  * schemes lose one of them, and that is not a duplicate being collapsed —
@@ -275,7 +410,7 @@ export function contributionsOf(
275
410
  ): HookContributions {
276
411
  const security = new Map<string, SecurityRequirement>();
277
412
  const conditions = new Map<string, readonly SecurityRequirement[]>();
278
- const responses = new Map<string, DocumentedResponse>();
413
+ const responses: DocumentedResponse[] = [];
279
414
 
280
415
  for (const slot of Object.values(chains)) {
281
416
  for (const hook of slot) {
@@ -311,20 +446,14 @@ export function contributionsOf(
311
446
  }
312
447
  }
313
448
 
314
- for (const response of docs.responses ?? []) {
315
- const key = `${response.status} ${response.description}`;
316
-
317
- if (!responses.has(key)) {
318
- responses.set(key, response);
319
- }
320
- }
449
+ responses.push(...(docs.responses ?? []));
321
450
  }
322
451
  }
323
452
 
324
453
  return {
325
454
  security: [...security.values()],
326
455
  conditions: [...conditions.values()],
327
- responses: [...responses.values()],
456
+ responses,
328
457
  };
329
458
  }
330
459
 
package/src/envelopes.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Error envelopes, one definition per status and code.
3
3
  *
4
- * An envelope reaches a document from four directions: a route's response
5
- * map, a hook's annotation, a guard's security requirement, and the
6
- * framework's own failures. Each used to be described where it arrived —
4
+ * An envelope reaches a document from five directions: a route's response
5
+ * map, its handler's annotation, a hook's annotation, a guard's security
6
+ * requirement, and the framework's own failures. Each used to be described
7
+ * where it arrived —
7
8
  * a route's inline, the rest by reference — so one `UNAUTHORIZED` could be
8
9
  * listed twice under one status, once as a `$ref` and once as the same
9
10
  * shape without a name, and a generated client got a named type next to an
@@ -14,11 +15,12 @@
14
15
  * discriminable: a client can branch on `error` only if every code under
15
16
  * the status has exactly one shape.
16
17
  *
17
- * The route's definition wins. It is the author's own contract — usually
18
- * the stricter one, a validator's `additionalProperties: false` — where a
19
- * hook's is the framework's generic envelope. The routes are therefore
20
- * read first, before any operation is built, so the winner does not depend
21
- * on which route happens to come first in the table.
18
+ * The route's definition wins, its handler's annotation counting as the
19
+ * route's own. It is the author's own contract — usually the stricter one,
20
+ * a validator's `additionalProperties: false` — where a hook's is the
21
+ * framework's generic envelope. The routes are therefore read first,
22
+ * before any operation is built, so the winner does not depend on which
23
+ * route happens to come first in the table.
22
24
  *
23
25
  * What an envelope looks like — how one is described, where its code is,
24
26
  * what a client discriminates on — is the document's error format, which
package/src/index.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  * @module
20
20
  */
21
21
 
22
- import type { App } from "@tetsujs/core";
23
- import { contributionsOf } from "./annotations.ts";
22
+ import type { App, RouteTableEntry } from "@tetsujs/core";
23
+ import { contributionsOf, handlerDocsOf } from "./annotations.ts";
24
24
  import { schemaComponents } from "./components.ts";
25
25
  import type { OpenApiDocument, PathItemObject, TagObject } from "./document.ts";
26
26
  import { envelopes as envelopeDefinitions } from "./envelopes.ts";
@@ -33,6 +33,7 @@ import { unresolvedRefs } from "./refs.ts";
33
33
  export type {
34
34
  DocumentedHeader,
35
35
  DocumentedResponse,
36
+ HandlerDocs,
36
37
  HookContributions,
37
38
  HookDocs,
38
39
  SecurityAlternatives,
@@ -157,7 +158,9 @@ export interface GeneratorResult {
157
158
  *
158
159
  * So are routes marked `docs: { hidden: true }`, and everything they would
159
160
  * have contributed with them: a security scheme only a hidden route
160
- * requires has nothing left to describe.
161
+ * requires has nothing left to describe. A route that says nothing either
162
+ * way is hidden when its handler was annotated so — see {@link documented}
163
+ * — and `docs: { hidden: false }` shows it all the same.
161
164
  *
162
165
  * Every route of the table becomes an operation under its path template,
163
166
  * with the parameters, body and responses its schemas describe. What the
@@ -175,9 +178,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
175
178
  resolveFormat(options.errors),
176
179
  );
177
180
  const ids = operationIds();
178
- const described = app.entries.filter(
179
- (entry) => !entry.ws && !entry.def.docs?.hidden,
180
- );
181
+ const described = app.entries.filter((entry) => !entry.ws && !hidden(entry));
181
182
 
182
183
  for (const entry of described) {
183
184
  declareEnvelopes(entry, envelopes);
@@ -274,6 +275,16 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
274
275
  return { document, warnings };
275
276
  }
276
277
 
278
+ /**
279
+ * Whether a route stays out of the document: by its own word when it said
280
+ * one, `true` or `false`, and by its handler's when it said nothing.
281
+ */
282
+ function hidden(entry: RouteTableEntry): boolean {
283
+ return (
284
+ entry.def.docs?.hidden ?? handlerDocsOf(entry.def.handler)?.hidden ?? false
285
+ );
286
+ }
287
+
277
288
  /**
278
289
  * Reports each reference in the document that resolves to nothing: in an
279
290
  * operation, at the route it describes; in a definition of