@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/README.md +108 -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/emit.d.ts +1 -0
- package/dist/src/emit.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 +103 -19
- package/dist/src/index.js.map +6 -6
- 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/emit.ts +1 -0
- package/src/index.ts +85 -1
- package/src/operation.ts +132 -27
- package/src/testing.ts +453 -0
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
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
|
|
628
|
-
readonly placeholder?:
|
|
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
|
|
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: (
|
|
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
|
+
}
|