@tetsujs/openapi 0.5.1 → 0.5.2
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 +91 -10
- package/dist/src/controller.d.ts +12 -0
- package/dist/src/controller.d.ts.map +1 -1
- package/dist/src/document.d.ts +17 -0
- package/dist/src/document.d.ts.map +1 -1
- package/dist/src/index.d.ts +27 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +40 -3
- package/dist/src/index.js.map +4 -4
- package/dist/src/testing.d.ts +69 -0
- package/dist/src/testing.d.ts.map +1 -0
- package/dist/src/testing.js +203 -0
- package/dist/src/testing.js.map +10 -0
- package/package.json +7 -3
- package/src/controller.ts +28 -1
- package/src/document.ts +19 -0
- package/src/index.ts +85 -1
- package/src/testing.ts +453 -0
package/src/testing.ts
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checking a response against the document, in a test.
|
|
3
|
+
*
|
|
4
|
+
* The document describes what every operation can answer; a test provokes
|
|
5
|
+
* one answer. `assertDescribed` holds the two together: the status is
|
|
6
|
+
* declared, the body is one the status describes, a header asked about is
|
|
7
|
+
* documented. What it catches is what a client generated from the document
|
|
8
|
+
* would trip over — an error code nobody declared, a field the schema says
|
|
9
|
+
* is always there and is not.
|
|
10
|
+
*
|
|
11
|
+
* It checks only what a test provoked, which is why it can be strict: the
|
|
12
|
+
* core does not compare a thrown error with the route's response map,
|
|
13
|
+
* because a guard's refusal would be reported on every route it runs on;
|
|
14
|
+
* a test that provoked the refusal asks about that one response.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { assertDescribed } from "@tetsujs/openapi/testing";
|
|
18
|
+
*
|
|
19
|
+
* const { document } = openapi(app, { info });
|
|
20
|
+
*
|
|
21
|
+
* test("a wrong code is what the document says", async () => {
|
|
22
|
+
* const res = await request("/session", { method: "POST", body });
|
|
23
|
+
*
|
|
24
|
+
* await assertDescribed(document, "POST /session", res);
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { OpenApiDocument, ResponseObject } from "./document.ts";
|
|
32
|
+
|
|
33
|
+
/** What `assertDescribed` checks besides the status and the body's shape. */
|
|
34
|
+
export interface AssertDescribedOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Checks the body against its schema in full, with the JSON Schema
|
|
37
|
+
* validator of your choice — this package has none, and without one
|
|
38
|
+
* only the top level is compared: required fields, fields that are a
|
|
39
|
+
* `const`. Return `true` when the body is valid, or what is wrong.
|
|
40
|
+
*
|
|
41
|
+
* The schema stands on its own: every `$ref` into the document's
|
|
42
|
+
* components points into its own `$defs`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const ajv = new Ajv2020();
|
|
47
|
+
*
|
|
48
|
+
* await assertDescribed(document, "POST /session", res, {
|
|
49
|
+
* validate: (schema, body) => ajv.validate(schema, body) || ajv.errorsText(),
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
readonly validate?: (
|
|
54
|
+
schema: Record<string, unknown>,
|
|
55
|
+
body: unknown,
|
|
56
|
+
) => true | string;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Headers the status must declare when the response carries them —
|
|
60
|
+
* `retry-after`. Only those named: a response carries headers no
|
|
61
|
+
* document describes, `content-type` and `x-request-id` among them.
|
|
62
|
+
*/
|
|
63
|
+
readonly headers?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Throws unless the response is one the document describes for the
|
|
68
|
+
* operation, listing every way it is not.
|
|
69
|
+
*
|
|
70
|
+
* `request` names the operation as the test called it — a method and the
|
|
71
|
+
* path it requested, `"GET /users/42"` — matched against the document's
|
|
72
|
+
* path templates. The body is read from a clone, and the test can still
|
|
73
|
+
* read the response.
|
|
74
|
+
*/
|
|
75
|
+
export async function assertDescribed(
|
|
76
|
+
document: OpenApiDocument,
|
|
77
|
+
request: string,
|
|
78
|
+
response: Response,
|
|
79
|
+
options: AssertDescribedOptions = {},
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const problems = await problemsOf(document, request, response, options);
|
|
82
|
+
|
|
83
|
+
if (problems.length > 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${request} answered ${response.status}, which the document does not describe:\n${problems.map((problem) => `- ${problem}`).join("\n")}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function problemsOf(
|
|
91
|
+
document: OpenApiDocument,
|
|
92
|
+
request: string,
|
|
93
|
+
response: Response,
|
|
94
|
+
options: AssertDescribedOptions,
|
|
95
|
+
): Promise<string[]> {
|
|
96
|
+
const [method = "", target = ""] = request.split(" ");
|
|
97
|
+
const path = target.split("?")[0] ?? "";
|
|
98
|
+
const template = templateOf(document, path);
|
|
99
|
+
const operation =
|
|
100
|
+
template === undefined
|
|
101
|
+
? undefined
|
|
102
|
+
: document.paths[template]?.[method.toLowerCase()];
|
|
103
|
+
|
|
104
|
+
if (operation === undefined) {
|
|
105
|
+
return [`there is no operation for ${method.toUpperCase()} ${path}`];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const status = String(response.status);
|
|
109
|
+
const described = operation.responses[status];
|
|
110
|
+
|
|
111
|
+
if (described === undefined) {
|
|
112
|
+
return [
|
|
113
|
+
`${status}, which the operation does not declare: it declares ${Object.keys(operation.responses).join(", ")}`,
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
...(await bodyProblems(document, status, described, response, options)),
|
|
119
|
+
...headerProblems(status, described, response, options.headers ?? []),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The path template a requested path belongs to: the one whose segments
|
|
125
|
+
* match it, `{param}` matching any one segment. Of several, the one with
|
|
126
|
+
* 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.
|
|
129
|
+
*/
|
|
130
|
+
function templateOf(
|
|
131
|
+
document: OpenApiDocument,
|
|
132
|
+
path: string,
|
|
133
|
+
): string | undefined {
|
|
134
|
+
const segments = path.split("/").filter(Boolean);
|
|
135
|
+
|
|
136
|
+
let best: { template: string; literals: number } | undefined;
|
|
137
|
+
|
|
138
|
+
for (const template of Object.keys(document.paths)) {
|
|
139
|
+
const parts = template.split("/").filter(Boolean);
|
|
140
|
+
const last = parts.at(-1) ?? "";
|
|
141
|
+
const rest = isParameter(last) && segments.length > parts.length;
|
|
142
|
+
|
|
143
|
+
if (parts.length !== segments.length && !rest) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let literals = 0;
|
|
148
|
+
let matches = true;
|
|
149
|
+
|
|
150
|
+
for (const [index, part] of parts.entries()) {
|
|
151
|
+
if (isParameter(part)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (part !== segments[index]) {
|
|
156
|
+
matches = false;
|
|
157
|
+
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
literals += 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (matches && (best === undefined || literals > best.literals)) {
|
|
165
|
+
best = { template, literals };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return best?.template;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isParameter(segment: string): boolean {
|
|
173
|
+
return segment.startsWith("{") && segment.endsWith("}");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function bodyProblems(
|
|
177
|
+
document: OpenApiDocument,
|
|
178
|
+
status: string,
|
|
179
|
+
described: ResponseObject,
|
|
180
|
+
response: Response,
|
|
181
|
+
options: AssertDescribedOptions,
|
|
182
|
+
): Promise<string[]> {
|
|
183
|
+
const text = await response.clone().text();
|
|
184
|
+
const content = described.content ?? {};
|
|
185
|
+
const mediaTypes = Object.keys(content);
|
|
186
|
+
|
|
187
|
+
if (text === "") {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (mediaTypes.length === 0) {
|
|
192
|
+
return [`a body, where its ${status} describes none`];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const mediaType = (response.headers.get("content-type") ?? "")
|
|
196
|
+
.split(";")[0]
|
|
197
|
+
?.trim();
|
|
198
|
+
|
|
199
|
+
if (mediaType === undefined || !mediaTypes.includes(mediaType)) {
|
|
200
|
+
return [
|
|
201
|
+
`a body of type "${mediaType ?? ""}", where its ${status} describes ${mediaTypes.join(", ")}`,
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const schema = content[mediaType]?.schema;
|
|
206
|
+
|
|
207
|
+
if (schema === undefined || !mediaType.endsWith("json")) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let body: unknown;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
body = JSON.parse(text);
|
|
215
|
+
} catch {
|
|
216
|
+
return [`a body that is not the JSON its ${status} describes`];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const problems = shapeProblems(document, status, schema, body);
|
|
220
|
+
|
|
221
|
+
if (options.validate) {
|
|
222
|
+
const verdict = options.validate(standalone(document, schema), body);
|
|
223
|
+
|
|
224
|
+
if (verdict !== true) {
|
|
225
|
+
problems.push(verdict);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return problems;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* What the top level of a body gets wrong against every alternative its
|
|
234
|
+
* status describes — nothing, when one of them fits. Named by the field
|
|
235
|
+
* that tells the alternatives apart, when they have one: the code of an
|
|
236
|
+
* envelope, compared with the codes the status lists.
|
|
237
|
+
*/
|
|
238
|
+
function shapeProblems(
|
|
239
|
+
document: OpenApiDocument,
|
|
240
|
+
status: string,
|
|
241
|
+
schema: Record<string, unknown>,
|
|
242
|
+
body: unknown,
|
|
243
|
+
): string[] {
|
|
244
|
+
const alternatives = alternativesOf(document, schema);
|
|
245
|
+
const misfits = alternatives.map((alternative) =>
|
|
246
|
+
misfitOf(alternative, body),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (misfits.some((misfit) => misfit === undefined)) {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const field = tellingField(alternatives);
|
|
254
|
+
|
|
255
|
+
if (field !== undefined && isRecord(body) && field in body) {
|
|
256
|
+
const listed: unknown[] = alternatives
|
|
257
|
+
.map((alternative) => constOf(propertiesOf(alternative)[field]))
|
|
258
|
+
.filter((value) => value !== undefined);
|
|
259
|
+
|
|
260
|
+
if (!listed.includes(body[field])) {
|
|
261
|
+
return [
|
|
262
|
+
`${field} ${JSON.stringify(body[field])}, which its ${status} does not list: ${listed.map(String).join(", ")}`,
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const reasons = [...new Set(misfits)];
|
|
268
|
+
|
|
269
|
+
return [
|
|
270
|
+
reasons.length === 1
|
|
271
|
+
? `a body that ${reasons[0]}`
|
|
272
|
+
: `a body that fits none of the ${alternatives.length} its ${status} describes: ${reasons.join("; ")}`,
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The alternatives a schema stands for, each `$ref` followed. */
|
|
277
|
+
function alternativesOf(
|
|
278
|
+
document: OpenApiDocument,
|
|
279
|
+
schema: SchemaView,
|
|
280
|
+
): SchemaView[] {
|
|
281
|
+
const resolved = resolve(document, schema);
|
|
282
|
+
const union = resolved.anyOf ?? resolved.oneOf;
|
|
283
|
+
|
|
284
|
+
if (Array.isArray(union)) {
|
|
285
|
+
return union.flatMap((branch) =>
|
|
286
|
+
isRecord(branch) ? alternativesOf(document, branch) : [],
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return [resolved];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function resolve(document: OpenApiDocument, schema: SchemaView): SchemaView {
|
|
294
|
+
const ref = schema.$ref;
|
|
295
|
+
|
|
296
|
+
if (typeof ref !== "string" || !ref.startsWith("#/components/schemas/")) {
|
|
297
|
+
return schema;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const target =
|
|
301
|
+
document.components?.schemas?.[ref.slice("#/components/schemas/".length)];
|
|
302
|
+
|
|
303
|
+
return isRecord(target) ? target : schema;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* How a body fails one alternative at the top level, or `undefined` when
|
|
308
|
+
* it does not: a required field missing, a `const` field different.
|
|
309
|
+
*/
|
|
310
|
+
function misfitOf(alternative: SchemaView, body: unknown): string | undefined {
|
|
311
|
+
const properties = propertiesOf(alternative);
|
|
312
|
+
const required = Array.isArray(alternative.required)
|
|
313
|
+
? (alternative.required as string[])
|
|
314
|
+
: [];
|
|
315
|
+
|
|
316
|
+
if (alternative.type === "object" || required.length > 0) {
|
|
317
|
+
if (!isRecord(body)) {
|
|
318
|
+
return "is not an object";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const missing = required.filter((name) => !(name in body));
|
|
322
|
+
|
|
323
|
+
if (missing.length > 0) {
|
|
324
|
+
return `lacks ${missing.join(", ")}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
328
|
+
const expected = constOf(property);
|
|
329
|
+
|
|
330
|
+
if (expected !== undefined && name in body && body[name] !== expected) {
|
|
331
|
+
return `has ${name} ${JSON.stringify(body[name])} where it is ${JSON.stringify(expected)}`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The field whose `const` differs from one alternative to the next — the
|
|
341
|
+
* code of an envelope — or none.
|
|
342
|
+
*/
|
|
343
|
+
function tellingField(alternatives: readonly SchemaView[]): string | undefined {
|
|
344
|
+
const [first] = alternatives;
|
|
345
|
+
|
|
346
|
+
if (first === undefined) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return Object.keys(propertiesOf(first)).find(
|
|
351
|
+
(name) =>
|
|
352
|
+
name !== "status" &&
|
|
353
|
+
alternatives.every(
|
|
354
|
+
(alternative) =>
|
|
355
|
+
typeof constOf(propertiesOf(alternative)[name]) === "string",
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function propertiesOf(schema: SchemaView): Record<string, unknown> {
|
|
361
|
+
return isRecord(schema.properties) ? schema.properties : {};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function constOf(value: unknown): unknown {
|
|
365
|
+
if (!isRecord(value)) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const property: SchemaView = value;
|
|
370
|
+
|
|
371
|
+
if ("const" in property) {
|
|
372
|
+
return property.const;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return Array.isArray(property.enum) && property.enum.length === 1
|
|
376
|
+
? property.enum[0]
|
|
377
|
+
: undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A schema that needs nothing but itself: the document's components as its
|
|
382
|
+
* `$defs`, and every reference into them pointed there — what a validator
|
|
383
|
+
* that knows JSON Schema and not OpenAPI can compile.
|
|
384
|
+
*/
|
|
385
|
+
function standalone(
|
|
386
|
+
document: OpenApiDocument,
|
|
387
|
+
schema: Record<string, unknown>,
|
|
388
|
+
): Record<string, unknown> {
|
|
389
|
+
const repoint = (value: unknown): unknown => {
|
|
390
|
+
if (Array.isArray(value)) {
|
|
391
|
+
return value.map(repoint);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!isRecord(value)) {
|
|
395
|
+
return value;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return Object.fromEntries(
|
|
399
|
+
Object.entries(value).map(([key, inner]) => [
|
|
400
|
+
key,
|
|
401
|
+
key === "$ref" && typeof inner === "string"
|
|
402
|
+
? inner.replace("#/components/schemas/", "#/$defs/")
|
|
403
|
+
: repoint(inner),
|
|
404
|
+
]),
|
|
405
|
+
);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
...(repoint(schema) as Record<string, unknown>),
|
|
410
|
+
$defs: repoint(document.components?.schemas ?? {}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function headerProblems(
|
|
415
|
+
status: string,
|
|
416
|
+
described: ResponseObject,
|
|
417
|
+
response: Response,
|
|
418
|
+
names: readonly string[],
|
|
419
|
+
): string[] {
|
|
420
|
+
const declared = Object.keys(described.headers ?? {}).map((name) =>
|
|
421
|
+
name.toLowerCase(),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
return names
|
|
425
|
+
.filter(
|
|
426
|
+
(name) =>
|
|
427
|
+
response.headers.has(name) && !declared.includes(name.toLowerCase()),
|
|
428
|
+
)
|
|
429
|
+
.map(
|
|
430
|
+
(name) => `the header "${name}", which its ${status} does not declare`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A schema as this module reads it: any keyword, with the ones it looks
|
|
436
|
+
* into named, so reading them does not depend on the reader's
|
|
437
|
+
* `noPropertyAccessFromIndexSignature`.
|
|
438
|
+
*/
|
|
439
|
+
interface SchemaView {
|
|
440
|
+
readonly $ref?: unknown;
|
|
441
|
+
readonly anyOf?: unknown;
|
|
442
|
+
readonly oneOf?: unknown;
|
|
443
|
+
readonly type?: unknown;
|
|
444
|
+
readonly required?: unknown;
|
|
445
|
+
readonly properties?: unknown;
|
|
446
|
+
readonly const?: unknown;
|
|
447
|
+
readonly enum?: unknown;
|
|
448
|
+
readonly [keyword: string]: unknown;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
452
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
453
|
+
}
|