@tetsujs/openapi 0.5.2 → 0.6.0
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.
- package/README.md +63 -18
- package/dist/src/document.d.ts +1 -0
- package/dist/src/document.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +159 -26
- package/dist/src/index.js.map +7 -6
- package/dist/src/operation.d.ts.map +1 -1
- package/dist/src/refs.d.ts +36 -0
- package/dist/src/refs.d.ts.map +1 -0
- package/dist/src/testing.d.ts +3 -0
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +8 -4
- package/dist/src/testing.js.map +3 -3
- package/package.json +3 -3
- package/src/document.ts +1 -0
- package/src/index.ts +51 -11
- package/src/operation.ts +161 -12
- package/src/page.ts +13 -4
- package/src/refs.ts +136 -0
- package/src/testing.ts +20 -5
package/dist/src/testing.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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=
|
|
206
|
+
//# debugId=7B8B75FA0C83D4F464756E2164756E21
|
|
203
207
|
//# sourceMappingURL=testing.js.map
|
package/dist/src/testing.js.map
CHANGED
|
@@ -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": ";;
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": ";;AA6EA,eAAsB,eAAe,CACnC,UACA,SACA,UACA,UAAkC,CAAC,GACpB;AAAA,EACf,MAAM,WAAW,MAAM,WAAW,UAAU,SAAS,UAAU,OAAO;AAAA,EAEtE,IAAI,SAAS,SAAS,GAAG;AAAA,IACvB,MAAM,IAAI,MACR,GAAG,oBAAoB,SAAS;AAAA,EAAkD,SAAS,IAAI,CAAC,YAAY,KAAK,SAAS,EAAE,KAAK;AAAA,CAAI,GACvI;AAAA,EACF;AAAA;AAGF,eAAe,UAAU,CACvB,UACA,SACA,UACA,SACmB;AAAA,EACnB,OAAO,SAAS,IAAI,SAAS,MAAM,QAAQ,MAAM,GAAG;AAAA,EACpD,MAAM,OAAO,OAAO,MAAM,GAAG,EAAE,MAAM;AAAA,EACrC,MAAM,WAAW,WAAW,UAAU,IAAI;AAAA,EAC1C,MAAM,YACJ,aAAa,YACT,YACA,SAAS,MAAM,YAAY,OAAO,YAAY;AAAA,EAEpD,IAAI,cAAc,WAAW;AAAA,IAC3B,OAAO,CAAC,6BAA6B,OAAO,YAAY,KAAK,MAAM;AAAA,EACrE;AAAA,EAEA,MAAM,SAAS,OAAO,SAAS,MAAM;AAAA,EACrC,MAAM,YAAY,UAAU,UAAU;AAAA,EAEtC,IAAI,cAAc,WAAW;AAAA,IAC3B,OAAO;AAAA,MACL,GAAG,6DAA6D,OAAO,KAAK,UAAU,SAAS,EAAE,KAAK,IAAI;AAAA,IAC5G;AAAA,EACF;AAAA,EAEA,OAAO;AAAA,IACL,GAAI,MAAM,aAAa,UAAU,QAAQ,WAAW,UAAU,OAAO;AAAA,IACrE,GAAG,eAAe,QAAQ,WAAW,UAAU,QAAQ,WAAW,CAAC,CAAC;AAAA,EACtE;AAAA;AAYF,SAAS,UAAU,CACjB,UACA,MACoB;AAAA,EACpB,MAAM,WAAW,KAAK,MAAM,GAAG,EAAE,OAAO,OAAO;AAAA,EAE/C,IAAI;AAAA,EAEJ,WAAW,YAAY,OAAO,KAAK,SAAS,KAAK,GAAG;AAAA,IAClD,MAAM,QAAQ,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AAAA,IAChD,MAAM,OAAO,MAAM,GAAG,EAAE,KAAK;AAAA,IAC7B,MAAM,OAAO,SAAS,gBAAgB,SAAS,SAAS,MAAM;AAAA,IAE9D,IAAI,MAAM,WAAW,SAAS,UAAU,CAAC,MAAM;AAAA,MAC7C;AAAA,IACF;AAAA,IAEA,IAAI,WAAW;AAAA,IACf,IAAI,UAAU;AAAA,IAEd,YAAY,OAAO,SAAS,MAAM,QAAQ,GAAG;AAAA,MAC3C,IAAI,YAAY,IAAI,GAAG;AAAA,QACrB;AAAA,MACF;AAAA,MAEA,IAAI,SAAS,SAAS,QAAQ;AAAA,QAC5B,UAAU;AAAA,QAEV;AAAA,MACF;AAAA,MAEA,YAAY;AAAA,IACd;AAAA,IAEA,IAAI,YAAY,SAAS,aAAa,WAAW,KAAK,WAAW;AAAA,MAC/D,OAAO,EAAE,UAAU,SAAS;AAAA,IAC9B;AAAA,EACF;AAAA,EAEA,OAAO,MAAM;AAAA;AAGf,SAAS,WAAW,CAAC,SAA0B;AAAA,EAC7C,OAAO,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG;AAAA;AAGxD,eAAe,YAAY,CACzB,UACA,QACA,WACA,UACA,SACmB;AAAA,EACnB,MAAM,OAAO,MAAM,SAAS,MAAM,EAAE,KAAK;AAAA,EACzC,MAAM,UAAU,UAAU,WAAW,CAAC;AAAA,EACtC,MAAM,aAAa,OAAO,KAAK,OAAO;AAAA,EAEtC,IAAI,SAAS,IAAI;AAAA,IACf,OAAO,WAAW,WAAW,IACzB,CAAC,IACD;AAAA,MACE,4BAA4B,oBAAoB,WAAW,KAAK,IAAI;AAAA,IACtE;AAAA,EACN;AAAA,EAEA,IAAI,WAAW,WAAW,GAAG;AAAA,IAC3B,OAAO,CAAC,qBAAqB,uBAAuB;AAAA,EACtD;AAAA,EAEA,MAAM,aAAa,SAAS,QAAQ,IAAI,cAAc,KAAK,IACxD,MAAM,GAAG,EAAE,IACV,KAAK;AAAA,EAET,IAAI,cAAc,aAAa,CAAC,WAAW,SAAS,SAAS,GAAG;AAAA,IAC9D,OAAO;AAAA,MACL,mBAAmB,aAAa,kBAAkB,oBAAoB,WAAW,KAAK,IAAI;AAAA,IAC5F;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,QAAQ,YAAY;AAAA,EAEnC,IAAI,WAAW,aAAa,CAAC,UAAU,SAAS,MAAM,GAAG;AAAA,IACvD,OAAO,CAAC;AAAA,EACV;AAAA,EAEA,IAAI;AAAA,EAEJ,IAAI;AAAA,IACF,OAAO,KAAK,MAAM,IAAI;AAAA,IACtB,MAAM;AAAA,IACN,OAAO,CAAC,mCAAmC,kBAAkB;AAAA;AAAA,EAG/D,MAAM,WAAW,cAAc,UAAU,QAAQ,QAAQ,IAAI;AAAA,EAE7D,IAAI,QAAQ,UAAU;AAAA,IACpB,MAAM,UAAU,QAAQ,SAAS,WAAW,UAAU,MAAM,GAAG,IAAI;AAAA,IAEnE,IAAI,YAAY,MAAM;AAAA,MACpB,SAAS,KAAK,OAAO;AAAA,IACvB;AAAA,EACF;AAAA,EAEA,OAAO;AAAA;AAST,SAAS,aAAa,CACpB,UACA,QACA,QACA,MACU;AAAA,EACV,MAAM,eAAe,eAAe,UAAU,MAAM;AAAA,EACpD,MAAM,UAAU,aAAa,IAAI,CAAC,gBAChC,SAAS,aAAa,IAAI,CAC5B;AAAA,EAEA,IAAI,QAAQ,KAAK,CAAC,WAAW,WAAW,SAAS,GAAG;AAAA,IAClD,OAAO,CAAC;AAAA,EACV;AAAA,EAEA,MAAM,QAAQ,aAAa,YAAY;AAAA,EAEvC,IAAI,UAAU,aAAa,SAAS,IAAI,KAAK,SAAS,MAAM;AAAA,IAC1D,MAAM,SAAoB,aACvB,IAAI,CAAC,gBAAgB,QAAQ,aAAa,WAAW,EAAE,MAAM,CAAC,EAC9D,OAAO,CAAC,UAAU,UAAU,SAAS;AAAA,IAExC,IAAI,CAAC,OAAO,SAAS,KAAK,MAAM,GAAG;AAAA,MACjC,OAAO;AAAA,QACL,GAAG,SAAS,KAAK,UAAU,KAAK,MAAM,gBAAgB,yBAAyB,OAAO,IAAI,MAAM,EAAE,KAAK,IAAI;AAAA,MAC7G;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,UAAU,CAAC,GAAG,IAAI,IAAI,OAAO,CAAC;AAAA,EAEpC,OAAO;AAAA,IACL,QAAQ,WAAW,IACf,eAAe,QAAQ,OACvB,gCAAgC,aAAa,cAAc,qBAAqB,QAAQ,KAAK,IAAI;AAAA,EACvG;AAAA;AAIF,SAAS,cAAc,CACrB,UACA,QACc;AAAA,EACd,MAAM,WAAW,QAAQ,UAAU,MAAM;AAAA,EACzC,MAAM,QAAQ,SAAS,SAAS,SAAS;AAAA,EAEzC,IAAI,MAAM,QAAQ,KAAK,GAAG;AAAA,IACxB,OAAO,MAAM,QAAQ,CAAC,WACpB,SAAS,MAAM,IAAI,eAAe,UAAU,MAAM,IAAI,CAAC,CACzD;AAAA,EACF;AAAA,EAEA,OAAO,CAAC,QAAQ;AAAA;AAGlB,SAAS,OAAO,CAAC,UAA2B,QAAgC;AAAA,EAC1E,MAAM,MAAM,OAAO;AAAA,EAEnB,IAAI,OAAO,QAAQ,YAAY,CAAC,IAAI,WAAW,uBAAuB,GAAG;AAAA,IACvE,OAAO;AAAA,EACT;AAAA,EAEA,MAAM,SACJ,SAAS,YAAY,UAAU,IAAI,MAAM,wBAAwB,MAAM;AAAA,EAEzE,OAAO,SAAS,MAAM,IAAI,SAAS;AAAA;AAOrC,SAAS,QAAQ,CAAC,aAAyB,MAAmC;AAAA,EAC5E,MAAM,aAAa,aAAa,WAAW;AAAA,EAC3C,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAC9C,YAAY,WACb,CAAC;AAAA,EAEL,IAAI,YAAY,SAAS,YAAY,SAAS,SAAS,GAAG;AAAA,IACxD,IAAI,CAAC,SAAS,IAAI,GAAG;AAAA,MACnB,OAAO;AAAA,IACT;AAAA,IAEA,MAAM,UAAU,SAAS,OAAO,CAAC,SAAS,EAAE,QAAQ,KAAK;AAAA,IAEzD,IAAI,QAAQ,SAAS,GAAG;AAAA,MACtB,OAAO,SAAS,QAAQ,KAAK,IAAI;AAAA,IACnC;AAAA,IAEA,YAAY,MAAM,aAAa,OAAO,QAAQ,UAAU,GAAG;AAAA,MACzD,MAAM,WAAW,QAAQ,QAAQ;AAAA,MAEjC,IAAI,aAAa,aAAa,QAAQ,QAAQ,KAAK,UAAU,UAAU;AAAA,QACrE,OAAO,OAAO,QAAQ,KAAK,UAAU,KAAK,KAAK,iBAAiB,KAAK,UAAU,QAAQ;AAAA,MACzF;AAAA,IACF;AAAA,EACF;AAAA,EAEA;AAAA;AAOF,SAAS,YAAY,CAAC,cAAyD;AAAA,EAC7E,OAAO,SAAS;AAAA,EAEhB,IAAI,UAAU,WAAW;AAAA,IACvB;AAAA,EACF;AAAA,EAEA,OAAO,OAAO,KAAK,aAAa,KAAK,CAAC,EAAE,KACtC,CAAC,SACC,SAAS,YACT,aAAa,MACX,CAAC,gBACC,OAAO,QAAQ,aAAa,WAAW,EAAE,KAAK,MAAM,QACxD,CACJ;AAAA;AAGF,SAAS,YAAY,CAAC,QAA6C;AAAA,EACjE,OAAO,SAAS,OAAO,UAAU,IAAI,OAAO,aAAa,CAAC;AAAA;AAG5D,SAAS,OAAO,CAAC,OAAyB;AAAA,EACxC,IAAI,CAAC,SAAS,KAAK,GAAG;AAAA,IACpB;AAAA,EACF;AAAA,EAEA,MAAM,WAAuB;AAAA,EAE7B,IAAI,WAAW,UAAU;AAAA,IACvB,OAAO,SAAS;AAAA,EAClB;AAAA,EAEA,OAAO,MAAM,QAAQ,SAAS,IAAI,KAAK,SAAS,KAAK,WAAW,IAC5D,SAAS,KAAK,KACd;AAAA;AAQN,SAAS,UAAU,CACjB,UACA,QACyB;AAAA,EACzB,MAAM,UAAU,CAAC,UAA4B;AAAA,IAC3C,IAAI,MAAM,QAAQ,KAAK,GAAG;AAAA,MACxB,OAAO,MAAM,IAAI,OAAO;AAAA,IAC1B;AAAA,IAEA,IAAI,CAAC,SAAS,KAAK,GAAG;AAAA,MACpB,OAAO;AAAA,IACT;AAAA,IAEA,OAAO,OAAO,YACZ,OAAO,QAAQ,KAAK,EAAE,IAAI,EAAE,KAAK,WAAW;AAAA,MAC1C;AAAA,MACA,QAAQ,UAAU,OAAO,UAAU,WAC/B,MAAM,QAAQ,yBAAyB,UAAU,IACjD,QAAQ,KAAK;AAAA,IACnB,CAAC,CACH;AAAA;AAAA,EAGF,OAAO;AAAA,OACD,QAAQ,MAAM;AAAA,IAClB,OAAO,QAAQ,SAAS,YAAY,WAAW,CAAC,CAAC;AAAA,EACnD;AAAA;AAGF,SAAS,cAAc,CACrB,QACA,WACA,UACA,OACU;AAAA,EACV,MAAM,WAAW,OAAO,KAAK,UAAU,WAAW,CAAC,CAAC,EAAE,IAAI,CAAC,SACzD,KAAK,YAAY,CACnB;AAAA,EAEA,MAAM,aAAa,MAChB,OACC,CAAC,SACC,SAAS,QAAQ,IAAI,IAAI,KAAK,CAAC,SAAS,SAAS,KAAK,YAAY,CAAC,CACvE,EACC,IACC,CAAC,SAAS,eAAe,oBAAoB,yBAC/C;AAAA,EAEF,MAAM,UAAU,OAAO,QAAQ,UAAU,WAAW,CAAC,CAAC,EACnD,OAAO,EAAE,MAAM,YAAY,OAAO,YAAY,CAAC,SAAS,QAAQ,IAAI,IAAI,CAAC,EACzE,IAAI,EAAE,UAAU,cAAc,oBAAoB,iBAAiB;AAAA,EAEtE,OAAO,CAAC,GAAG,YAAY,GAAG,OAAO;AAAA;AAoBnC,SAAS,QAAQ,CAAC,OAAkD;AAAA,EAClE,OAAO,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AAAA;",
|
|
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.
|
|
3
|
+
"version": "0.6.0",
|
|
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.
|
|
41
|
+
"@tetsujs/core": "^0.6.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@tetsujs/core": "0.
|
|
44
|
+
"@tetsujs/core": "0.6.0"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public"
|
package/src/document.ts
CHANGED
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
+
const declared = declaredResponses(entry);
|
|
564
|
+
|
|
565
|
+
for (const [status, { body, ...rest }] of declared) {
|
|
432
566
|
const described =
|
|
433
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
156
|
+
return `<div id="swagger-ui" data-url="${url}"></div>`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
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 `&` 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
|
-
|
|
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
|
}
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the references in a document that lead nowhere.
|
|
3
|
+
*
|
|
4
|
+
* A validator's JSON Schema is embedded in the document as it is, and the
|
|
5
|
+
* references inside it were written for a schema that stood alone. `#` is
|
|
6
|
+
* the root of the schema, `#/$defs/Node` a definition next to it — but
|
|
7
|
+
* embedded, both resolve against the root of the document instead, which
|
|
8
|
+
* is no schema and has no `$defs`. Zod's recursive and named schemas,
|
|
9
|
+
* ArkType's scopes and Valibot's `lazy` all emit such references, and the
|
|
10
|
+
* document they end up in is invalid without anything saying so.
|
|
11
|
+
*
|
|
12
|
+
* Moving the definitions into `components/schemas` and rewriting the
|
|
13
|
+
* references to them would make those schemas describable. Until then the
|
|
14
|
+
* generator says which references broke, which is what it owes a reader:
|
|
15
|
+
* a document that is silently wrong is worse than one that says so.
|
|
16
|
+
*
|
|
17
|
+
* Internal to the package.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Keywords whose values are data, not schemas: a `$ref` inside an example
|
|
24
|
+
* or a `const` is a string someone meant to send, not a reference.
|
|
25
|
+
*/
|
|
26
|
+
const data = new Set(["const", "default", "enum", "example", "examples"]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The local references under `node` that resolve to nothing, each once, in
|
|
30
|
+
* the order they are met.
|
|
31
|
+
*
|
|
32
|
+
* A reference resolves against the closest enclosing schema with an `$id`,
|
|
33
|
+
* as JSON Schema has it — the document, when there is none. A reference
|
|
34
|
+
* that is not local, such as TypeBox's `Node` under `$id: "Node"`, and one
|
|
35
|
+
* to an anchor rather than a pointer are left alone: neither is what the
|
|
36
|
+
* libraries this is about emit, and resolving them takes more than a walk.
|
|
37
|
+
*
|
|
38
|
+
* The walk keeps its own stack and visits each object once per base: a
|
|
39
|
+
* schema may be deeper than the call stack, or contain itself, and neither
|
|
40
|
+
* is a reason for the document not to be generated.
|
|
41
|
+
*/
|
|
42
|
+
export function unresolvedRefs(node: unknown, document: object): string[] {
|
|
43
|
+
const unresolved = new Set<string>();
|
|
44
|
+
const visited = new Map<object, Set<object>>();
|
|
45
|
+
const pending: [value: unknown, base: object][] = [[node, document]];
|
|
46
|
+
|
|
47
|
+
for (let next = pending.pop(); next; next = pending.pop()) {
|
|
48
|
+
const [value, base] = next;
|
|
49
|
+
|
|
50
|
+
if (typeof value !== "object" || value === null) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bases = visited.get(value) ?? new Set<object>();
|
|
55
|
+
|
|
56
|
+
if (bases.has(base)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
visited.set(value, bases.add(base));
|
|
61
|
+
|
|
62
|
+
const schema = value as Record<string, unknown>;
|
|
63
|
+
const resource =
|
|
64
|
+
!Array.isArray(value) && typeof schema["$id"] === "string"
|
|
65
|
+
? schema
|
|
66
|
+
: base;
|
|
67
|
+
const ref = Array.isArray(value) ? undefined : schema["$ref"];
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
typeof ref === "string" &&
|
|
71
|
+
ref.startsWith("#") &&
|
|
72
|
+
!resolves(ref.slice(1), resource, document)
|
|
73
|
+
) {
|
|
74
|
+
unresolved.add(ref);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const children = Object.entries(schema).filter(
|
|
78
|
+
([keyword]) => Array.isArray(value) || !data.has(keyword),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
82
|
+
pending.push([children[index]?.[1], resource]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [...unresolved];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a fragment names a schema within `base`.
|
|
91
|
+
*
|
|
92
|
+
* The empty fragment is `base` itself — a schema when it is a resource,
|
|
93
|
+
* and nothing a schema can refer to when it is the document. A pointer is
|
|
94
|
+
* followed token by token, decoded as a URI fragment and then as JSON
|
|
95
|
+
* Pointer; anything else is an anchor, and is taken on trust.
|
|
96
|
+
*/
|
|
97
|
+
function resolves(fragment: string, base: object, document: object): boolean {
|
|
98
|
+
if (fragment === "") {
|
|
99
|
+
return base !== document;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!fragment.startsWith("/")) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let target: unknown = base;
|
|
107
|
+
|
|
108
|
+
for (const token of fragment.slice(1).split("/")) {
|
|
109
|
+
if (typeof target !== "object" || target === null) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const key = decoded(token);
|
|
114
|
+
|
|
115
|
+
if (key === undefined || !Object.hasOwn(target, key)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
target = (target as Record<string, unknown>)[key];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
(typeof target === "object" && target !== null) ||
|
|
124
|
+
typeof target === "boolean"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function decoded(token: string): string | undefined {
|
|
129
|
+
try {
|
|
130
|
+
return decodeURIComponent(token)
|
|
131
|
+
.replaceAll("~1", "/")
|
|
132
|
+
.replaceAll("~0", "~");
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|