@tetsujs/openapi 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,7 +34,7 @@ function templateOf(document, path) {
34
34
  for (const template of Object.keys(document.paths)) {
35
35
  const parts = template.split("/").filter(Boolean);
36
36
  const last = parts.at(-1) ?? "";
37
- const rest = isParameter(last) && segments.length > parts.length;
37
+ const rest = last === "{wildcard}" && segments.length > parts.length;
38
38
  if (parts.length !== segments.length && !rest) {
39
39
  continue;
40
40
  }
@@ -64,7 +64,9 @@ async function bodyProblems(document, status, described, response, options) {
64
64
  const content = described.content ?? {};
65
65
  const mediaTypes = Object.keys(content);
66
66
  if (text === "") {
67
- return [];
67
+ return mediaTypes.length === 0 ? [] : [
68
+ `an empty body, where its ${status} describes ${mediaTypes.join(", ")}`
69
+ ];
68
70
  }
69
71
  if (mediaTypes.length === 0) {
70
72
  return [`a body, where its ${status} describes none`];
@@ -190,7 +192,9 @@ function standalone(document, schema) {
190
192
  }
191
193
  function headerProblems(status, described, response, names) {
192
194
  const declared = Object.keys(described.headers ?? {}).map((name) => name.toLowerCase());
193
- return names.filter((name) => response.headers.has(name) && !declared.includes(name.toLowerCase())).map((name) => `the header "${name}", which its ${status} does not declare`);
195
+ const undeclared = names.filter((name) => response.headers.has(name) && !declared.includes(name.toLowerCase())).map((name) => `the header "${name}", which its ${status} does not declare`);
196
+ const missing = Object.entries(described.headers ?? {}).filter(([name, header]) => header.required && !response.headers.has(name)).map(([name]) => `no header "${name}", which its ${status} requires`);
197
+ return [...undeclared, ...missing];
194
198
  }
195
199
  function isRecord(value) {
196
200
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -199,5 +203,5 @@ export {
199
203
  assertDescribed
200
204
  };
201
205
 
202
- //# debugId=608BF03DC34E084D64756E2164756E21
206
+ //# debugId=7B8B75FA0C83D4F464756E2164756E21
203
207
  //# 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 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"
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"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "608BF03DC34E084D64756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "7B8B75FA0C83D4F464756E2164756E21",
9
9
  "names": []
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tetsujs/openapi",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
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.5.3"
41
+ "@tetsujs/core": "^0.6.1"
42
42
  },
43
43
  "devDependencies": {
44
- "@tetsujs/core": "0.5.3"
44
+ "@tetsujs/core": "0.6.1"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"
package/src/document.ts CHANGED
@@ -49,6 +49,7 @@ export type ContentMap = Record<
49
49
  /** A header a response carries. */
50
50
  export interface HeaderObject {
51
51
  readonly description?: string;
52
+ readonly required?: boolean;
52
53
  readonly schema: JsonSchema;
53
54
  }
54
55
 
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import type { ErrorFormat } from "./errors.ts";
28
28
  import { resolveFormat } from "./errors.ts";
29
29
  import { declareEnvelopes, operationIds, operationOf } from "./operation.ts";
30
30
  import { toTemplate } from "./parameters.ts";
31
+ import { unresolvedRefs } from "./refs.ts";
31
32
 
32
33
  export type {
33
34
  DocumentedHeader,
@@ -259,18 +260,57 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
259
260
  }
260
261
 
261
262
  const tags = options.tags ? tagsOf(options.tags, used, warnings) : undefined;
262
-
263
- return {
264
- document: {
265
- openapi: "3.1.0",
266
- info: options.info,
267
- ...(options.servers ? { servers: options.servers } : {}),
268
- ...(tags ? { tags } : {}),
269
- paths,
270
- ...componentsOf(schemes, components.schemas),
271
- },
272
- warnings,
263
+ const document: OpenApiDocument = {
264
+ openapi: "3.1.0",
265
+ info: options.info,
266
+ ...(options.servers ? { servers: options.servers } : {}),
267
+ ...(tags ? { tags } : {}),
268
+ paths,
269
+ ...componentsOf(schemes, components.schemas),
273
270
  };
271
+
272
+ refsOf(document, occupied, warnings);
273
+
274
+ return { document, warnings };
275
+ }
276
+
277
+ /**
278
+ * Reports each reference in the document that resolves to nothing: in an
279
+ * operation, at the route it describes; in a definition of
280
+ * `components/schemas`, for the document, naming the definition.
281
+ *
282
+ * Checked on the finished document, since a reference may point at what
283
+ * the document defines anywhere — a failure envelope registered by a
284
+ * later route included.
285
+ */
286
+ function refsOf(
287
+ document: OpenApiDocument,
288
+ routes: ReadonlyMap<string, string>,
289
+ warnings: GeneratorWarning[],
290
+ ): void {
291
+ const unresolved = (ref: string) =>
292
+ `refers to "${ref}", which is no schema in the document: references are embedded as they are, and resolve against the document's root`;
293
+
294
+ for (const [template, item] of Object.entries(document.paths)) {
295
+ for (const [method, operation] of Object.entries(item)) {
296
+ const route = routes.get(`${method} ${template}`) ?? "";
297
+
298
+ for (const ref of unresolvedRefs(operation, document)) {
299
+ warnings.push({ route, message: `a schema ${unresolved(ref)}` });
300
+ }
301
+ }
302
+ }
303
+
304
+ const schemas = document.components?.schemas ?? {};
305
+
306
+ for (const [name, schema] of Object.entries(schemas)) {
307
+ for (const ref of unresolvedRefs(schema, document)) {
308
+ warnings.push({
309
+ route: "",
310
+ message: `components/schemas/${name} ${unresolved(ref)}`,
311
+ });
312
+ }
313
+ }
274
314
  }
275
315
 
276
316
  /**
package/src/operation.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  AnySchema,
15
15
  AppOptions,
16
16
  BodyType,
17
+ ResponseEntry,
17
18
  RouteTableEntry,
18
19
  } from "@tetsujs/core";
19
20
  import { errorBody } from "@tetsujs/core";
@@ -100,9 +101,8 @@ export function declareEnvelopes(
100
101
  entry: RouteTableEntry,
101
102
  envelopes: Envelopes,
102
103
  ): void {
103
- for (const [status, schema] of declaredResponses(entry)) {
104
- const described =
105
- schema === null ? undefined : emitted(schema, "", () => {});
104
+ for (const [status, { body }] of declaredResponses(entry)) {
105
+ const described = body === null ? undefined : emitted(body, "", () => {});
106
106
 
107
107
  const code = envelopes.format.code;
108
108
 
@@ -116,10 +116,17 @@ export function declareEnvelopes(
116
116
  }
117
117
  }
118
118
 
119
+ /** One status of a route's response map, whichever form it was written in. */
120
+ interface Declared {
121
+ readonly body: AnySchema | null;
122
+ readonly headers?: AnySchema;
123
+ readonly cookies?: AnySchema;
124
+ }
125
+
119
126
  /** The route's response map as pairs; a single schema is its `200`. */
120
127
  function declaredResponses(
121
128
  entry: RouteTableEntry,
122
- ): [status: string, schema: AnySchema | null][] {
129
+ ): [status: string, declared: Declared][] {
123
130
  const response = entry.def.schema?.response;
124
131
 
125
132
  if (!response) {
@@ -127,10 +134,127 @@ function declaredResponses(
127
134
  }
128
135
 
129
136
  if ("~standard" in response) {
130
- return [["200", response as AnySchema]];
137
+ return [["200", { body: response as AnySchema }]];
138
+ }
139
+
140
+ return Object.entries(response).map(([status, value]) => [
141
+ status,
142
+ asDeclared(value as AnySchema | ResponseEntry | null),
143
+ ]);
144
+ }
145
+
146
+ function asDeclared(value: AnySchema | ResponseEntry | null): Declared {
147
+ if (value === null || "~standard" in value) {
148
+ return { body: value as AnySchema | null };
149
+ }
150
+
151
+ return {
152
+ body: value.body ?? null,
153
+ ...(value.headers === undefined ? {} : { headers: value.headers }),
154
+ ...(value.cookies === undefined ? {} : { cookies: value.cookies }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * The headers a declared status says it leaves with: one per property of
160
+ * its `headers` schema, required as the schema says, and its cookies as
161
+ * the one header that sets them.
162
+ *
163
+ * A property's `description` is the header's: it is what a renderer
164
+ * shows in the header's row.
165
+ */
166
+ function declaredHeaders(
167
+ declared: Declared,
168
+ warn: (message: string) => void,
169
+ ): Record<string, HeaderObject> | undefined {
170
+ const headers: Record<string, HeaderObject> = {};
171
+
172
+ const [properties, required] = shapeOf(
173
+ declared.headers,
174
+ "a response's headers",
175
+ warn,
176
+ );
177
+
178
+ for (const [name, property] of Object.entries(properties)) {
179
+ const { description, ...schema } = property;
180
+
181
+ headers[name] = {
182
+ ...(typeof description === "string" ? { description } : {}),
183
+ ...(required.has(name) ? { required: true } : {}),
184
+ schema: schema as JsonSchema,
185
+ };
186
+ }
187
+
188
+ const cookies = setCookie(declared.cookies, warn);
189
+
190
+ if (cookies) {
191
+ headers["set-cookie"] = cookies;
192
+ }
193
+
194
+ return Object.keys(headers).length > 0 ? headers : undefined;
195
+ }
196
+
197
+ /**
198
+ * The cookies a status sets, as the header that sets them. OpenAPI has no
199
+ * object for a cookie a response sets — only a `set-cookie` header — so the
200
+ * cookies are listed in its description, by name, each with its own
201
+ * description; the header is required when any of them is.
202
+ */
203
+ function setCookie(
204
+ schema: AnySchema | undefined,
205
+ warn: (message: string) => void,
206
+ ): HeaderObject | undefined {
207
+ const [properties, required] = shapeOf(schema, "a response's cookies", warn);
208
+
209
+ const names = Object.keys(properties);
210
+
211
+ if (names.length === 0) {
212
+ return undefined;
131
213
  }
132
214
 
133
- return Object.entries(response) as [string, AnySchema | null][];
215
+ const lines = names.map((name) => {
216
+ const description = properties[name]?.["description"];
217
+
218
+ return typeof description === "string"
219
+ ? `- \`${name}\`: ${description}`
220
+ : `- \`${name}\``;
221
+ });
222
+
223
+ return {
224
+ description: ["Sets these cookies:", "", ...lines].join("\n"),
225
+ ...(required.size > 0 ? { required: true } : {}),
226
+ schema: { type: "string" },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The properties of an object schema, and which of them it requires.
232
+ *
233
+ * Read as the schema's input: a response's headers and cookies are checked
234
+ * against it and sent as the handler set them, not as the schema returns
235
+ * them — unlike a body, whose checked value is what gets serialized. A
236
+ * field with a default is one the handler may leave out, and the response
237
+ * then leaves without it.
238
+ */
239
+ function shapeOf(
240
+ schema: AnySchema | undefined,
241
+ subject: string,
242
+ warn: (message: string) => void,
243
+ ): [Record<string, Record<string, unknown>>, Set<string>] {
244
+ const described = schema
245
+ ? emitted(schema, subject, warn, "input")
246
+ : undefined;
247
+
248
+ const properties = (described?.properties ?? {}) as Record<
249
+ string,
250
+ Record<string, unknown>
251
+ >;
252
+
253
+ const required = new Set(
254
+ Array.isArray(described?.required) ? (described.required as string[]) : [],
255
+ );
256
+
257
+ return [properties, required];
134
258
  }
135
259
 
136
260
  /**
@@ -325,7 +449,15 @@ function requestBody(
325
449
  warn: (message: string) => void,
326
450
  ): { required: boolean; content: ContentMap } | undefined {
327
451
  const declared = entry.def.schema?.body;
328
- const bodyType = entry.def.bodyType ?? (declared ? "json" : undefined);
452
+
453
+ /**
454
+ * A body is read when the core reads one: a schema, a declared shape, or
455
+ * `rawBody` — which parses JSON as any body does, next to keeping its
456
+ * bytes. Without a schema the document says what the body is, not what
457
+ * is in it.
458
+ */
459
+ const bodyType =
460
+ entry.def.bodyType ?? (declared || entry.def.rawBody ? "json" : undefined);
329
461
 
330
462
  if (!bodyType) {
331
463
  return undefined;
@@ -428,9 +560,13 @@ function responses(
428
560
  answers.set(status, [...(answers.get(status) ?? []), answer]);
429
561
  };
430
562
 
431
- for (const [status, schema] of declaredResponses(entry)) {
563
+ const declared = declaredResponses(entry);
564
+
565
+ for (const [status, { body, ...rest }] of declared) {
432
566
  const described =
433
- schema === null ? undefined : emitted(schema, "a response", warn);
567
+ body === null ? undefined : emitted(body, "a response", warn);
568
+
569
+ const headers = declaredHeaders({ body, ...rest }, warn);
434
570
 
435
571
  add(status, {
436
572
  placeholder: describeStatus(status),
@@ -438,10 +574,17 @@ function responses(
438
574
  schemas: described
439
575
  ? branchesFor(unionOnly(described), Number(status), envelopes, warn)
440
576
  : [],
577
+ ...(headers === undefined ? {} : { headers }),
441
578
  });
442
579
  }
443
580
 
444
- if (![...answers.keys()].some((status) => status.startsWith("2"))) {
581
+ /**
582
+ * A route that declares nothing answers with something: a `200`, as far
583
+ * as the document can tell. One that declares only a `303`, or only its
584
+ * failures, has said what it answers with, and a success it never sends
585
+ * would be a response a generated client waits for in vain.
586
+ */
587
+ if (declared.length === 0) {
445
588
  add("200", { placeholder: "Successful response", said: [], schemas: [] });
446
589
  }
447
590
 
@@ -506,7 +649,13 @@ function failures(
506
649
  return ref as unknown as Record<string, unknown>;
507
650
  };
508
651
 
509
- if (schema?.params || schema?.query || schema?.headers || schema?.body) {
652
+ if (
653
+ schema?.params ||
654
+ schema?.query ||
655
+ schema?.headers ||
656
+ schema?.cookies ||
657
+ schema?.body
658
+ ) {
510
659
  found.push([
511
660
  options.validationStatus,
512
661
  {
@@ -575,7 +724,7 @@ function failures(
575
724
  ]);
576
725
  }
577
726
 
578
- if (schema?.body || entry.def.bodyType) {
727
+ if (schema?.body || entry.def.bodyType || entry.def.rawBody) {
579
728
  const unparsable = parseFailure(entry.def.bodyType);
580
729
 
581
730
  if (unparsable) {
package/src/page.ts CHANGED
@@ -136,7 +136,7 @@ export function docsPage(options: DocsPageOptions): string {
136
136
  "<body>",
137
137
  body(options.ui, url),
138
138
  `<script src="${escapeHtml(assets.script)}"${verified(assets.integrity?.script)}></script>`,
139
- bootstrap(options.ui, url),
139
+ bootstrap(options.ui),
140
140
  "</body>",
141
141
  "</html>",
142
142
  ]
@@ -153,17 +153,26 @@ function body(ui: DocsUi, url: string): string {
153
153
  return `<redoc spec-url="${url}"></redoc>`;
154
154
  }
155
155
 
156
- return '<div id="swagger-ui"></div>';
156
+ return `<div id="swagger-ui" data-url="${url}"></div>`;
157
157
  }
158
158
 
159
- function bootstrap(ui: DocsUi, url: string): string {
159
+ /**
160
+ * The call that starts Swagger UI, which alone of the three is started by
161
+ * a script. It reads the document's address from the element's
162
+ * attribute, where the other two carry it: a value escaped for HTML is
163
+ * decoded there and not inside a script, where `&` stayed `&amp;` and the
164
+ * renderer fetched a URL nobody served. So the script holds no value of
165
+ * the application's, and needs no escaping of its own.
166
+ */
167
+ function bootstrap(ui: DocsUi): string {
160
168
  if (ui !== "swagger-ui") {
161
169
  return "";
162
170
  }
163
171
 
164
172
  return [
165
173
  "<script>",
166
- `SwaggerUIBundle({ url: "${url}", dom_id: "#swagger-ui" });`,
174
+ 'const root = document.getElementById("swagger-ui");',
175
+ 'SwaggerUIBundle({ url: root.dataset.url, dom_id: "#swagger-ui" });',
167
176
  "</script>",
168
177
  ].join("\n");
169
178
  }