@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/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}`; a template ending in a parameter also stands for a
128
- * wildcard, which matches the rest of the path.
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 = isParameter(last) && segments.length > parts.length;
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
- return names
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
  /**