@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.
- package/README.md +5 -401
- 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 +4 -4
- 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/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
|
+
}
|
package/src/testing.ts
CHANGED
|
@@ -59,6 +59,9 @@ export interface AssertDescribedOptions {
|
|
|
59
59
|
* Headers the status must declare when the response carries them —
|
|
60
60
|
* `retry-after`. Only those named: a response carries headers no
|
|
61
61
|
* document describes, `content-type` and `x-request-id` among them.
|
|
62
|
+
*
|
|
63
|
+
* The other way needs no list: a header the status declares required —
|
|
64
|
+
* a `location`, a `set-cookie` — is one the response must carry.
|
|
62
65
|
*/
|
|
63
66
|
readonly headers?: readonly string[];
|
|
64
67
|
}
|
|
@@ -124,8 +127,10 @@ async function problemsOf(
|
|
|
124
127
|
* The path template a requested path belongs to: the one whose segments
|
|
125
128
|
* match it, `{param}` matching any one segment. Of several, the one with
|
|
126
129
|
* the most literal segments, as the router prefers `/users/me` to
|
|
127
|
-
* `/users/{id}
|
|
128
|
-
*
|
|
130
|
+
* `/users/{id}`. A template ending in `{wildcard}` — the name the
|
|
131
|
+
* generator gives a trailing `*` — matches the rest of the path; any other
|
|
132
|
+
* matches as many segments as it has, so a mistyped path is no operation
|
|
133
|
+
* rather than the one it happens to start like.
|
|
129
134
|
*/
|
|
130
135
|
function templateOf(
|
|
131
136
|
document: OpenApiDocument,
|
|
@@ -138,7 +143,7 @@ function templateOf(
|
|
|
138
143
|
for (const template of Object.keys(document.paths)) {
|
|
139
144
|
const parts = template.split("/").filter(Boolean);
|
|
140
145
|
const last = parts.at(-1) ?? "";
|
|
141
|
-
const rest =
|
|
146
|
+
const rest = last === "{wildcard}" && segments.length > parts.length;
|
|
142
147
|
|
|
143
148
|
if (parts.length !== segments.length && !rest) {
|
|
144
149
|
continue;
|
|
@@ -185,7 +190,11 @@ async function bodyProblems(
|
|
|
185
190
|
const mediaTypes = Object.keys(content);
|
|
186
191
|
|
|
187
192
|
if (text === "") {
|
|
188
|
-
return
|
|
193
|
+
return mediaTypes.length === 0
|
|
194
|
+
? []
|
|
195
|
+
: [
|
|
196
|
+
`an empty body, where its ${status} describes ${mediaTypes.join(", ")}`,
|
|
197
|
+
];
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
if (mediaTypes.length === 0) {
|
|
@@ -421,7 +430,7 @@ function headerProblems(
|
|
|
421
430
|
name.toLowerCase(),
|
|
422
431
|
);
|
|
423
432
|
|
|
424
|
-
|
|
433
|
+
const undeclared = names
|
|
425
434
|
.filter(
|
|
426
435
|
(name) =>
|
|
427
436
|
response.headers.has(name) && !declared.includes(name.toLowerCase()),
|
|
@@ -429,6 +438,12 @@ function headerProblems(
|
|
|
429
438
|
.map(
|
|
430
439
|
(name) => `the header "${name}", which its ${status} does not declare`,
|
|
431
440
|
);
|
|
441
|
+
|
|
442
|
+
const missing = Object.entries(described.headers ?? {})
|
|
443
|
+
.filter(([name, header]) => header.required && !response.headers.has(name))
|
|
444
|
+
.map(([name]) => `no header "${name}", which its ${status} requires`);
|
|
445
|
+
|
|
446
|
+
return [...undeclared, ...missing];
|
|
432
447
|
}
|
|
433
448
|
|
|
434
449
|
/**
|