@tetsujs/openapi 0.5.0 → 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/src/operation.ts CHANGED
@@ -433,20 +433,16 @@ function responses(
433
433
  schema === null ? undefined : emitted(schema, "a response", warn);
434
434
 
435
435
  add(status, {
436
- description: describeStatus(status),
437
- placeholder: true,
436
+ placeholder: describeStatus(status),
437
+ said: described ? wordsOf(described, envelopes) : [],
438
438
  schemas: described
439
- ? branchesFor(described, Number(status), envelopes, warn)
439
+ ? branchesFor(unionOnly(described), Number(status), envelopes, warn)
440
440
  : [],
441
441
  });
442
442
  }
443
443
 
444
444
  if (![...answers.keys()].some((status) => status.startsWith("2"))) {
445
- add("200", {
446
- description: "Successful response",
447
- placeholder: true,
448
- schemas: [],
449
- });
445
+ add("200", { placeholder: "Successful response", said: [], schemas: [] });
450
446
  }
451
447
 
452
448
  for (const [status, answer] of failures(
@@ -514,7 +510,12 @@ function failures(
514
510
  found.push([
515
511
  options.validationStatus,
516
512
  {
517
- description: "Request failed schema validation",
513
+ said: [
514
+ {
515
+ code: "VALIDATION_FAILED",
516
+ text: "Request failed schema validation",
517
+ },
518
+ ],
518
519
  schemas: [
519
520
  envelopes.ref(
520
521
  options.validationStatus,
@@ -538,9 +539,13 @@ function failures(
538
539
  found.push([
539
540
  status,
540
541
  {
541
- description:
542
- requirement.description ??
543
- `Request did not satisfy ${requirement.name}`,
542
+ said: [
543
+ said(
544
+ requirement.error,
545
+ requirement.description ??
546
+ `Request did not satisfy ${requirement.name}`,
547
+ ),
548
+ ],
544
549
  schemas: [envelopeRef(status, requirement.error, requirement.message)],
545
550
  },
546
551
  ]);
@@ -554,7 +559,7 @@ function failures(
554
559
  found.push([
555
560
  response.status,
556
561
  {
557
- description: response.description,
562
+ said: [said(response.error, response.description)],
558
563
  schemas: described
559
564
  ? branchesFor(described, response.status, envelopes, warn)
560
565
  : [
@@ -577,7 +582,12 @@ function failures(
577
582
  found.push([
578
583
  400,
579
584
  {
580
- description: "Body could not be parsed in the declared shape",
585
+ said: [
586
+ said(
587
+ unparsable.error,
588
+ "Body could not be parsed in the declared shape",
589
+ ),
590
+ ],
581
591
  schemas: [envelopeRef(400, unparsable.error, unparsable.message)],
582
592
  },
583
593
  ]);
@@ -586,7 +596,9 @@ function failures(
586
596
  found.push([
587
597
  413,
588
598
  {
589
- description: "Body exceeded the configured size limit",
599
+ said: [
600
+ said("BODY_TOO_LARGE", "Body exceeded the configured size limit"),
601
+ ],
590
602
  schemas: [
591
603
  envelopeRef(
592
604
  413,
@@ -601,7 +613,12 @@ function failures(
601
613
  found.push([
602
614
  500,
603
615
  {
604
- description: "The request failed and nothing mapped the failure",
616
+ said: [
617
+ said(
618
+ "INTERNAL_SERVER_ERROR",
619
+ "The request failed and nothing mapped the failure",
620
+ ),
621
+ ],
605
622
  schemas: [
606
623
  envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
607
624
  ],
@@ -619,17 +636,66 @@ function failures(
619
636
  * reference to its definition; empty when the answer has no body to
620
637
  * describe.
621
638
  *
622
- * A route declares a status by its schema alone and has no words for it,
623
- * so its description is a `placeholder`: it stands for the status only
624
- * when nothing else describes it.
639
+ * `said` is what the answer says about itself, with the code each part is
640
+ * about when it is known: a hook's description, the framework's, what a
641
+ * route's schema says. A route whose schema says nothing has only its
642
+ * `placeholder` — the reason phrase — which stands for the status when
643
+ * nothing else describes it.
625
644
  */
626
645
  interface Answer {
627
- readonly description: string;
628
- readonly placeholder?: boolean;
646
+ readonly said: readonly Said[];
647
+ readonly placeholder?: string;
629
648
  readonly schemas: readonly Record<string, unknown>[];
630
649
  readonly headers?: Readonly<Record<string, HeaderObject>>;
631
650
  }
632
651
 
652
+ /** One description, and the code it describes when that is known. */
653
+ interface Said {
654
+ readonly code?: string;
655
+ readonly text: string;
656
+ }
657
+
658
+ function said(code: string | undefined, text: string): Said {
659
+ return code === undefined ? { text } : { code, text };
660
+ }
661
+
662
+ /**
663
+ * What a route's schema says about the status it is declared for: its own
664
+ * `description`, or — for a union that has none — each branch's, with the
665
+ * branch's code. A schema that says nothing says nothing here either.
666
+ */
667
+ function wordsOf(schema: JsonSchemaObject, envelopes: Envelopes): Said[] {
668
+ if (typeof schema.description === "string") {
669
+ return [said(envelopes.format.code(schema), schema.description)];
670
+ }
671
+
672
+ return branchesOf(unionOnly(schema), envelopes.format.code).flatMap(
673
+ (branch) =>
674
+ typeof branch.description === "string"
675
+ ? [said(envelopes.format.code(branch), branch.description)]
676
+ : [],
677
+ );
678
+ }
679
+
680
+ /**
681
+ * A union without the `description` it carried as a whole — which says
682
+ * what the status is, and is used as the status's description — so its
683
+ * branches can join the status's own alternatives. Anything but a union
684
+ * is kept as it is, its description included, for its definition.
685
+ */
686
+ function unionOnly(schema: JsonSchemaObject): JsonSchemaObject {
687
+ if (
688
+ typeof schema.description !== "string" ||
689
+ (!Array.isArray(schema.anyOf) && !Array.isArray(schema.oneOf))
690
+ ) {
691
+ return schema;
692
+ }
693
+
694
+ const { description: _, ...union } = schema;
695
+
696
+ return union;
697
+ }
698
+
633
699
  /**
634
700
  * Folds everything that answers with one status into one response.
635
701
  *
@@ -646,12 +712,10 @@ interface Answer {
646
712
  * alternative, not two.
647
713
  *
648
714
  * Headers are gathered the same way, the first description of a name
649
- * standing for all of them. Descriptions are joined, except a route's
650
- * placeholder next to one that says something.
715
+ * standing for all of them; descriptions as {@link wording} words them.
651
716
  */
652
717
  function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
653
718
  const schemas = distinct(list.flatMap((answer) => answer.schemas));
654
- const worded = list.filter((answer) => !answer.placeholder);
655
719
  const headers: Record<string, HeaderObject> = {};
656
720
 
657
721
  for (const answer of list) {
@@ -661,9 +725,7 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
661
725
  }
662
726
 
663
727
  return {
664
- description: (worded.length > 0 ? worded : list)
665
- .map((answer) => answer.description)
666
- .join("; "),
728
+ description: wording(list),
667
729
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
668
730
  ...(schemas.length > 0
669
731
  ? {
@@ -675,6 +737,49 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
675
737
  };
676
738
  }
677
739
 
740
+ /**
741
+ * The description of a status, from everything that answers with it.
742
+ *
743
+ * One thing said is the description. Several are a list, each item led
744
+ * by the code it is about, so a reader sees which description goes with
745
+ * which code — descriptions are CommonMark, and every renderer draws the
746
+ * list. The same code said the same way twice, by the route and a hook,
747
+ * is one item. With nothing said at all, the route's placeholder stands.
748
+ */
749
+ function wording(list: readonly Answer[]): string {
750
+ const seen = new Set<string>();
751
+ const items: Said[] = [];
752
+
753
+ for (const answer of list) {
754
+ for (const item of answer.said) {
755
+ const key = `${item.code ?? ""}\n${item.text}`;
756
+
757
+ if (!seen.has(key)) {
758
+ seen.add(key);
759
+ items.push(item);
760
+ }
761
+ }
762
+ }
763
+
764
+ const [only] = items;
765
+
766
+ if (items.length === 1 && only) {
767
+ return only.text;
768
+ }
769
+
770
+ if (items.length > 1) {
771
+ return items
772
+ .map((item) =>
773
+ item.code === undefined
774
+ ? `- ${item.text}`
775
+ : `- \`${item.code}\`: ${item.text}`,
776
+ )
777
+ .join("\n");
778
+ }
779
+
780
+ return list.find((answer) => answer.placeholder)?.placeholder ?? "";
781
+ }
782
+
678
783
  /**
679
784
  * The alternatives of a body as one schema.
680
785
  *
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
+ }