@tetsujs/openapi 0.6.2 → 0.6.4
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/dist/index-f49v8ege.js +28 -0
- package/dist/index-f49v8ege.js.map +10 -0
- package/dist/src/annotations.d.ts +97 -15
- package/dist/src/annotations.d.ts.map +1 -1
- package/dist/src/envelopes.d.ts +10 -8
- package/dist/src/envelopes.d.ts.map +1 -1
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +151 -47
- package/dist/src/index.js.map +6 -6
- package/dist/src/media.d.ts +30 -0
- package/dist/src/media.d.ts.map +1 -0
- package/dist/src/operation.d.ts +5 -2
- package/dist/src/operation.d.ts.map +1 -1
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +23 -7
- package/dist/src/testing.js.map +3 -3
- package/package.json +3 -3
- package/src/annotations.ts +154 -25
- package/src/envelopes.ts +10 -8
- package/src/index.ts +17 -6
- package/src/media.ts +57 -0
- package/src/operation.ts +249 -71
- package/src/testing.ts +48 -9
package/src/media.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media types, as the document's `content` keys them.
|
|
3
|
+
*
|
|
4
|
+
* The core checks a route's `contentType` when the route is declared; a
|
|
5
|
+
* response annotated with `documented()` reaches the document without the
|
|
6
|
+
* core seeing it, so the same rule is applied here, when it is annotated.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One part of a media type: HTTP's token characters, a range's star aside. */
|
|
12
|
+
const part = /^[!#$%&'+.^_`|~0-9a-z-]+$/i;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a value is a media type or a range of them, as a document's
|
|
16
|
+
* `content` names it: `text/csv`, `image/*`, the range of every type — and
|
|
17
|
+
* no parameters, which a key would carry into every comparison with what a
|
|
18
|
+
* response says.
|
|
19
|
+
*/
|
|
20
|
+
export function isMediaType(value: unknown): value is string {
|
|
21
|
+
if (typeof value !== "string") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [type, subtype, ...rest] = value.split("/");
|
|
26
|
+
|
|
27
|
+
if (type === undefined || subtype === undefined || rest.length > 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (type === "*") {
|
|
32
|
+
return subtype === "*";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return part.test(type) && (subtype === "*" || part.test(subtype));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a media type is the one a body is described as by default, and
|
|
40
|
+
* the framework sends a returned value with. Compared without regard to
|
|
41
|
+
* case, as media types are.
|
|
42
|
+
*/
|
|
43
|
+
export function isJson(type: string): boolean {
|
|
44
|
+
return type.toLowerCase() === "application/json";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a body under this key is JSON to parse: `application/json`, or
|
|
49
|
+
* a type with the `+json` suffix. A range is not — its schema describes
|
|
50
|
+
* the bytes of whatever type answers — and neither is `x-ndjson`, which
|
|
51
|
+
* is lines of JSON, not one value.
|
|
52
|
+
*/
|
|
53
|
+
export function parsesAsJson(type: string): boolean {
|
|
54
|
+
const lower = type.toLowerCase();
|
|
55
|
+
|
|
56
|
+
return lower === "application/json" || lower.endsWith("+json");
|
|
57
|
+
}
|
package/src/operation.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
HookContributions,
|
|
24
24
|
SecurityRequirement,
|
|
25
25
|
} from "./annotations.ts";
|
|
26
|
-
import { contributionsOf } from "./annotations.ts";
|
|
26
|
+
import { contributionsOf, handlerDocsOf } from "./annotations.ts";
|
|
27
27
|
import type { SchemaComponents } from "./components.ts";
|
|
28
28
|
import { failureName } from "./components.ts";
|
|
29
29
|
import type {
|
|
@@ -37,6 +37,7 @@ import { emitted } from "./emit.ts";
|
|
|
37
37
|
import type { Envelopes } from "./envelopes.ts";
|
|
38
38
|
import { branchesOf } from "./envelopes.ts";
|
|
39
39
|
import type { JsonSchema } from "./json-schema.ts";
|
|
40
|
+
import { isJson } from "./media.ts";
|
|
40
41
|
import { partParameters, pathParameters } from "./parameters.ts";
|
|
41
42
|
|
|
42
43
|
/** Describes one route. */
|
|
@@ -92,25 +93,44 @@ export function operationOf(
|
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
95
|
* Records the envelopes a route declares, so that its definitions are the
|
|
95
|
-
* ones the document keeps — whichever operation refers to them first.
|
|
96
|
+
* ones the document keeps — whichever operation refers to them first. What
|
|
97
|
+
* its handler was annotated with is the route's own too, and declares the
|
|
98
|
+
* same way.
|
|
96
99
|
*
|
|
97
100
|
* Runs before any operation is built. A schema that cannot describe itself
|
|
98
|
-
* is skipped without a word: building the operation says so, once.
|
|
101
|
+
* is skipped without a word: building the operation says so, once. A body
|
|
102
|
+
* of another type than JSON is no envelope.
|
|
99
103
|
*/
|
|
100
104
|
export function declareEnvelopes(
|
|
101
105
|
entry: RouteTableEntry,
|
|
102
106
|
envelopes: Envelopes,
|
|
103
107
|
): void {
|
|
104
|
-
|
|
105
|
-
const described = body === null ? undefined : emitted(body, "", () => {});
|
|
108
|
+
const own: [status: number, body: AnySchema][] = [];
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
for (const [status, { body, contentType }] of declaredResponses(entry)) {
|
|
111
|
+
if (body !== null && (contentType === undefined || isJson(contentType))) {
|
|
112
|
+
own.push([Number(status), body]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const response of handlerDocsOf(entry.def.handler)?.responses ?? []) {
|
|
117
|
+
const { contentType, schema, status } = response;
|
|
118
|
+
|
|
119
|
+
if (schema && (contentType === undefined || isJson(contentType))) {
|
|
120
|
+
own.push([status, schema]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const code = envelopes.format.code;
|
|
125
|
+
|
|
126
|
+
for (const [status, body] of own) {
|
|
127
|
+
const described = emitted(body, "", () => {});
|
|
108
128
|
|
|
109
129
|
for (const branch of described ? branchesOf(described, code) : []) {
|
|
110
130
|
const found = code(branch);
|
|
111
131
|
|
|
112
132
|
if (found !== undefined) {
|
|
113
|
-
envelopes.declare(
|
|
133
|
+
envelopes.declare(status, found, branch);
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
}
|
|
@@ -121,6 +141,7 @@ interface Declared {
|
|
|
121
141
|
readonly body: AnySchema | null;
|
|
122
142
|
readonly headers?: AnySchema;
|
|
123
143
|
readonly cookies?: AnySchema;
|
|
144
|
+
readonly contentType?: string;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
/** The route's response map as pairs; a single schema is its `200`. */
|
|
@@ -152,6 +173,9 @@ function asDeclared(value: AnySchema | ResponseEntry | null): Declared {
|
|
|
152
173
|
body: value.body ?? null,
|
|
153
174
|
...(value.headers === undefined ? {} : { headers: value.headers }),
|
|
154
175
|
...(value.cookies === undefined ? {} : { cookies: value.cookies }),
|
|
176
|
+
...(value.contentType === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { contentType: value.contentType }),
|
|
155
179
|
};
|
|
156
180
|
}
|
|
157
181
|
|
|
@@ -562,29 +586,49 @@ function responses(
|
|
|
562
586
|
|
|
563
587
|
const declared = declaredResponses(entry);
|
|
564
588
|
|
|
565
|
-
for (const [status, { body, ...rest }] of declared) {
|
|
589
|
+
for (const [status, { body, contentType, ...rest }] of declared) {
|
|
566
590
|
const described =
|
|
567
591
|
body === null ? undefined : emitted(body, "a response", warn);
|
|
568
592
|
|
|
569
593
|
const headers = declaredHeaders({ body, ...rest }, warn);
|
|
594
|
+
const own = contentType !== undefined && !isJson(contentType);
|
|
570
595
|
|
|
571
596
|
add(status, {
|
|
572
597
|
placeholder: describeStatus(status),
|
|
573
598
|
said: described ? wordsOf(described, envelopes) : [],
|
|
574
|
-
schemas:
|
|
575
|
-
|
|
576
|
-
|
|
599
|
+
schemas:
|
|
600
|
+
described && !own
|
|
601
|
+
? branchesFor(unionOnly(described), Number(status), envelopes, warn)
|
|
602
|
+
: [],
|
|
603
|
+
...(own ? { media: [mediaOf(contentType, described)] } : {}),
|
|
577
604
|
...(headers === undefined ? {} : { headers }),
|
|
578
605
|
});
|
|
579
606
|
}
|
|
580
607
|
|
|
608
|
+
const handled = handlerDocsOf(entry.def.handler)?.responses ?? [];
|
|
609
|
+
const envelopeRef = envelopeRefs(components, envelopes, warn);
|
|
610
|
+
|
|
611
|
+
for (const response of handled) {
|
|
612
|
+
add(
|
|
613
|
+
String(response.status),
|
|
614
|
+
documentedAnswer(
|
|
615
|
+
response,
|
|
616
|
+
"a handler's response",
|
|
617
|
+
envelopeRef,
|
|
618
|
+
envelopes,
|
|
619
|
+
warn,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
581
624
|
/**
|
|
582
625
|
* A route that declares nothing answers with something: a `200`, as far
|
|
583
626
|
* as the document can tell. One that declares only a `303`, or only its
|
|
584
627
|
* failures, has said what it answers with, and a success it never sends
|
|
585
|
-
* would be a response a generated client waits for in vain.
|
|
628
|
+
* would be a response a generated client waits for in vain. So has a
|
|
629
|
+
* route whose handler describes what it answers with.
|
|
586
630
|
*/
|
|
587
|
-
if (declared.length === 0) {
|
|
631
|
+
if (declared.length === 0 && handled.length === 0) {
|
|
588
632
|
add("200", { placeholder: "Successful response", said: [], schemas: [] });
|
|
589
633
|
}
|
|
590
634
|
|
|
@@ -622,32 +666,7 @@ function failures(
|
|
|
622
666
|
): [status: number, answer: Answer][] {
|
|
623
667
|
const schema = entry.def.schema;
|
|
624
668
|
const found: [number, Answer][] = [];
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Refers to the definition of one failure: the one of its status and
|
|
628
|
-
* code, or — for a failure whose code only the hook knows and did not
|
|
629
|
-
* declare — one of its own, named after the status.
|
|
630
|
-
*/
|
|
631
|
-
const envelopeRef = (
|
|
632
|
-
status: number,
|
|
633
|
-
error?: string,
|
|
634
|
-
message?: string,
|
|
635
|
-
fields?: DocumentedResponse["fields"],
|
|
636
|
-
): Record<string, unknown> => {
|
|
637
|
-
const described = envelopes.format.describe({
|
|
638
|
-
status,
|
|
639
|
-
...(error === undefined ? {} : { error }),
|
|
640
|
-
...(message === undefined ? {} : { message }),
|
|
641
|
-
fields: fields ?? {},
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
const ref =
|
|
645
|
-
error === undefined
|
|
646
|
-
? components.ref(failureName(status), described)
|
|
647
|
-
: envelopes.ref(status, error, described, warn);
|
|
648
|
-
|
|
649
|
-
return ref as unknown as Record<string, unknown>;
|
|
650
|
-
};
|
|
669
|
+
const envelopeRef = envelopeRefs(components, envelopes, warn);
|
|
651
670
|
|
|
652
671
|
if (
|
|
653
672
|
schema?.params ||
|
|
@@ -695,32 +714,24 @@ function failures(
|
|
|
695
714
|
`Request did not satisfy ${requirement.name}`,
|
|
696
715
|
),
|
|
697
716
|
],
|
|
698
|
-
schemas:
|
|
717
|
+
schemas:
|
|
718
|
+
status >= 400
|
|
719
|
+
? [envelopeRef(status, requirement.error, requirement.message)]
|
|
720
|
+
: [],
|
|
699
721
|
},
|
|
700
722
|
]);
|
|
701
723
|
}
|
|
702
724
|
|
|
703
725
|
for (const response of contributed.responses) {
|
|
704
|
-
const described = response.schema
|
|
705
|
-
? emitted(response.schema, "a hook's response", warn)
|
|
706
|
-
: undefined;
|
|
707
|
-
|
|
708
726
|
found.push([
|
|
709
727
|
response.status,
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
response.error,
|
|
718
|
-
response.message,
|
|
719
|
-
response.fields,
|
|
720
|
-
),
|
|
721
|
-
],
|
|
722
|
-
...(response.headers ? { headers: response.headers } : {}),
|
|
723
|
-
},
|
|
728
|
+
documentedAnswer(
|
|
729
|
+
response,
|
|
730
|
+
"a hook's response",
|
|
731
|
+
envelopeRef,
|
|
732
|
+
envelopes,
|
|
733
|
+
warn,
|
|
734
|
+
),
|
|
724
735
|
]);
|
|
725
736
|
}
|
|
726
737
|
|
|
@@ -777,13 +788,117 @@ function failures(
|
|
|
777
788
|
return found;
|
|
778
789
|
}
|
|
779
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Refers to the definition of one failure: the one of its status and code,
|
|
793
|
+
* or — for a failure whose code only its hook knows and did not declare —
|
|
794
|
+
* one of its own, named after the status.
|
|
795
|
+
*/
|
|
796
|
+
type EnvelopeRef = (
|
|
797
|
+
status: number,
|
|
798
|
+
error?: string,
|
|
799
|
+
message?: string,
|
|
800
|
+
fields?: DocumentedResponse["fields"],
|
|
801
|
+
) => Record<string, unknown>;
|
|
802
|
+
|
|
803
|
+
function envelopeRefs(
|
|
804
|
+
components: SchemaComponents,
|
|
805
|
+
envelopes: Envelopes,
|
|
806
|
+
warn: (message: string) => void,
|
|
807
|
+
): EnvelopeRef {
|
|
808
|
+
return (status, error, message, fields) => {
|
|
809
|
+
const described = envelopes.format.describe({
|
|
810
|
+
status,
|
|
811
|
+
...(error === undefined ? {} : { error }),
|
|
812
|
+
...(message === undefined ? {} : { message }),
|
|
813
|
+
fields: fields ?? {},
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
const ref =
|
|
817
|
+
error === undefined
|
|
818
|
+
? components.ref(failureName(status), described)
|
|
819
|
+
: envelopes.ref(status, error, described, warn);
|
|
820
|
+
|
|
821
|
+
return ref as unknown as Record<string, unknown>;
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* What a hook or a handler said it answers with, as an answer of its
|
|
827
|
+
* status.
|
|
828
|
+
*
|
|
829
|
+
* A body of a type of its own is described under that type. A schema
|
|
830
|
+
* without one is JSON. Neither is the framework's envelope for an error
|
|
831
|
+
* status — a hook refuses by throwing, and what it throws leaves in the
|
|
832
|
+
* envelope — and no body at all for any other: a handler's `304`, a hook's
|
|
833
|
+
* redirect.
|
|
834
|
+
*/
|
|
835
|
+
function documentedAnswer(
|
|
836
|
+
response: DocumentedResponse,
|
|
837
|
+
subject: string,
|
|
838
|
+
envelopeRef: EnvelopeRef,
|
|
839
|
+
envelopes: Envelopes,
|
|
840
|
+
warn: (message: string) => void,
|
|
841
|
+
): Answer {
|
|
842
|
+
const described = response.schema
|
|
843
|
+
? emitted(response.schema, subject, warn)
|
|
844
|
+
: undefined;
|
|
845
|
+
|
|
846
|
+
const answer = {
|
|
847
|
+
said: [said(response.error, response.description)],
|
|
848
|
+
...(response.headers ? { headers: response.headers } : {}),
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
if (response.contentType !== undefined && !isJson(response.contentType)) {
|
|
852
|
+
return {
|
|
853
|
+
...answer,
|
|
854
|
+
schemas: [],
|
|
855
|
+
media: [mediaOf(response.contentType, described)],
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (described) {
|
|
860
|
+
return {
|
|
861
|
+
...answer,
|
|
862
|
+
schemas: branchesFor(described, response.status, envelopes, warn),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
...answer,
|
|
868
|
+
schemas:
|
|
869
|
+
response.status >= 400
|
|
870
|
+
? [
|
|
871
|
+
envelopeRef(
|
|
872
|
+
response.status,
|
|
873
|
+
response.error,
|
|
874
|
+
response.message,
|
|
875
|
+
response.fields,
|
|
876
|
+
),
|
|
877
|
+
]
|
|
878
|
+
: [],
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** A body of a type other than JSON, and its schema when there is one. */
|
|
883
|
+
interface Media {
|
|
884
|
+
readonly type: string;
|
|
885
|
+
readonly schema?: Record<string, unknown>;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function mediaOf(type: string, schema: JsonSchemaObject | undefined): Media {
|
|
889
|
+
return schema === undefined
|
|
890
|
+
? { type }
|
|
891
|
+
: { type, schema: schema as Record<string, unknown> };
|
|
892
|
+
}
|
|
893
|
+
|
|
780
894
|
/**
|
|
781
895
|
* One way a route can answer with a status: what the route declared, what
|
|
782
896
|
* a hook declared, or what the framework answers by itself.
|
|
783
897
|
*
|
|
784
898
|
* `schemas` are the alternatives of the body, each envelope already a
|
|
785
899
|
* reference to its definition; empty when the answer has no body to
|
|
786
|
-
* describe.
|
|
900
|
+
* describe. `media` are bodies of other types than JSON, each described
|
|
901
|
+
* under its own.
|
|
787
902
|
*
|
|
788
903
|
* `said` is what the answer says about itself, with the code each part is
|
|
789
904
|
* about when it is known: a hook's description, the framework's, what a
|
|
@@ -795,6 +910,7 @@ interface Answer {
|
|
|
795
910
|
readonly said: readonly Said[];
|
|
796
911
|
readonly placeholder?: string;
|
|
797
912
|
readonly schemas: readonly Record<string, unknown>[];
|
|
913
|
+
readonly media?: readonly Media[];
|
|
798
914
|
readonly headers?: Readonly<Record<string, HeaderObject>>;
|
|
799
915
|
}
|
|
800
916
|
|
|
@@ -862,27 +978,56 @@ function unionOnly(schema: JsonSchemaObject): JsonSchemaObject {
|
|
|
862
978
|
*
|
|
863
979
|
* Headers are gathered the same way, the first description of a name
|
|
864
980
|
* standing for all of them; descriptions as {@link wording} words them.
|
|
981
|
+
*
|
|
982
|
+
* A body of another type is not an alternative of the JSON one but a
|
|
983
|
+
* second content of the same status, under its own media type: the
|
|
984
|
+
* envelope an API client gets for a `404` sits next to the page a
|
|
985
|
+
* browser does. JSON comes first; the others in the order they were said,
|
|
986
|
+
* each type once in lower case, as media types compare. A type one answer
|
|
987
|
+
* left undescribed takes any body, and is described by its type alone.
|
|
865
988
|
*/
|
|
866
989
|
function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
|
|
867
990
|
const schemas = distinct(list.flatMap((answer) => answer.schemas));
|
|
868
991
|
const headers: Record<string, HeaderObject> = {};
|
|
992
|
+
const media = new Map<string, Record<string, unknown>[]>();
|
|
993
|
+
const unconstrained = new Set<string>();
|
|
869
994
|
|
|
870
995
|
for (const answer of list) {
|
|
871
996
|
for (const [name, header] of Object.entries(answer.headers ?? {})) {
|
|
872
997
|
headers[name] ??= header;
|
|
873
998
|
}
|
|
999
|
+
|
|
1000
|
+
for (const { type, schema } of answer.media ?? []) {
|
|
1001
|
+
const key = type.toLowerCase();
|
|
1002
|
+
const alternatives = media.get(key) ?? [];
|
|
1003
|
+
|
|
1004
|
+
media.set(key, schema ? [...alternatives, schema] : alternatives);
|
|
1005
|
+
|
|
1006
|
+
if (!schema) {
|
|
1007
|
+
unconstrained.add(key);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const content: ContentMap = {};
|
|
1013
|
+
|
|
1014
|
+
if (schemas.length > 0) {
|
|
1015
|
+
content["application/json"] = { schema: unionOf(schemas, envelopes) };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
for (const [type, alternatives] of media) {
|
|
1019
|
+
const [only, ...others] = distinct(alternatives);
|
|
1020
|
+
|
|
1021
|
+
content[type] =
|
|
1022
|
+
only === undefined || unconstrained.has(type)
|
|
1023
|
+
? {}
|
|
1024
|
+
: { schema: others.length === 0 ? only : { anyOf: [only, ...others] } };
|
|
874
1025
|
}
|
|
875
1026
|
|
|
876
1027
|
return {
|
|
877
1028
|
description: wording(list),
|
|
878
1029
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
879
|
-
...(
|
|
880
|
-
? {
|
|
881
|
-
content: {
|
|
882
|
-
"application/json": { schema: unionOf(schemas, envelopes) },
|
|
883
|
-
},
|
|
884
|
-
}
|
|
885
|
-
: {}),
|
|
1030
|
+
...(Object.keys(content).length > 0 ? { content } : {}),
|
|
886
1031
|
};
|
|
887
1032
|
}
|
|
888
1033
|
|
|
@@ -893,7 +1038,9 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
|
|
|
893
1038
|
* by the code it is about, so a reader sees which description goes with
|
|
894
1039
|
* which code — descriptions are CommonMark, and every renderer draws the
|
|
895
1040
|
* list. The same code said the same way twice, by the route and a hook,
|
|
896
|
-
* is one item
|
|
1041
|
+
* is one item, and so are words with no code that a coded item already
|
|
1042
|
+
* says — the page of a 404 described as its envelope is. With nothing said
|
|
1043
|
+
* at all, the route's placeholder stands.
|
|
897
1044
|
*/
|
|
898
1045
|
function wording(list: readonly Answer[]): string {
|
|
899
1046
|
const seen = new Set<string>();
|
|
@@ -910,14 +1057,21 @@ function wording(list: readonly Answer[]): string {
|
|
|
910
1057
|
}
|
|
911
1058
|
}
|
|
912
1059
|
|
|
913
|
-
const
|
|
1060
|
+
const coded = new Set(
|
|
1061
|
+
items.flatMap((item) => (item.code === undefined ? [] : [item.text])),
|
|
1062
|
+
);
|
|
1063
|
+
const said = items.filter(
|
|
1064
|
+
(item) => item.code !== undefined || !coded.has(item.text),
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const [only] = said;
|
|
914
1068
|
|
|
915
|
-
if (
|
|
1069
|
+
if (said.length === 1 && only) {
|
|
916
1070
|
return only.text;
|
|
917
1071
|
}
|
|
918
1072
|
|
|
919
|
-
if (
|
|
920
|
-
return
|
|
1073
|
+
if (said.length > 1) {
|
|
1074
|
+
return said
|
|
921
1075
|
.map((item) =>
|
|
922
1076
|
item.code === undefined
|
|
923
1077
|
? `- ${item.text}`
|
|
@@ -993,16 +1147,40 @@ function distinct(
|
|
|
993
1147
|
});
|
|
994
1148
|
}
|
|
995
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* The description of a status nothing else describes. `default` and a range
|
|
1152
|
+
* such as `4XX`, OpenAPI's own keys, are no number: a reason phrase of one
|
|
1153
|
+
* read as `HTTP NaN`.
|
|
1154
|
+
*/
|
|
996
1155
|
function describeStatus(status: string): string {
|
|
997
1156
|
if (status === "204") {
|
|
998
1157
|
return "No content";
|
|
999
1158
|
}
|
|
1000
1159
|
|
|
1160
|
+
if (status === "default") {
|
|
1161
|
+
return "Any other response";
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const range = ranges[status.toUpperCase()];
|
|
1165
|
+
|
|
1166
|
+
if (range !== undefined) {
|
|
1167
|
+
return range;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1001
1170
|
return status.startsWith("2")
|
|
1002
1171
|
? "Successful response"
|
|
1003
1172
|
: errorBody(Number(status)).message;
|
|
1004
1173
|
}
|
|
1005
1174
|
|
|
1175
|
+
/** What a range of statuses is, by its key. */
|
|
1176
|
+
const ranges: Readonly<Record<string, string>> = {
|
|
1177
|
+
"1XX": "Informational response",
|
|
1178
|
+
"2XX": "Successful response",
|
|
1179
|
+
"3XX": "Redirection",
|
|
1180
|
+
"4XX": "Client error",
|
|
1181
|
+
"5XX": "Server error",
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1006
1184
|
/**
|
|
1007
1185
|
* What a validation failure carries besides the code and the message: every
|
|
1008
1186
|
* issue, with where it is. Handed to the error format as a field, for the
|
package/src/testing.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import type { OpenApiDocument, ResponseObject } from "./document.ts";
|
|
32
|
+
import { parsesAsJson } from "./media.ts";
|
|
32
33
|
|
|
33
34
|
/** What `assertDescribed` checks besides the status and the body's shape. */
|
|
34
35
|
export interface AssertDescribedOptions {
|
|
@@ -178,6 +179,29 @@ function isParameter(segment: string): boolean {
|
|
|
178
179
|
return segment.startsWith("{") && segment.endsWith("}");
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The media type of the document that describes a response's body: its
|
|
184
|
+
* own, then the range of its type, then the range of every type — the
|
|
185
|
+
* order in which OpenAPI lets the more specific one win. Compared without
|
|
186
|
+
* regard to case, as media types are.
|
|
187
|
+
*/
|
|
188
|
+
function keyFor(
|
|
189
|
+
described: readonly string[],
|
|
190
|
+
mediaType: string,
|
|
191
|
+
): string | undefined {
|
|
192
|
+
const [type] = mediaType.split("/");
|
|
193
|
+
|
|
194
|
+
for (const wanted of [mediaType, `${type}/*`, "*/*"]) {
|
|
195
|
+
const key = described.find((each) => each.toLowerCase() === wanted);
|
|
196
|
+
|
|
197
|
+
if (key !== undefined) {
|
|
198
|
+
return key;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
async function bodyProblems(
|
|
182
206
|
document: OpenApiDocument,
|
|
183
207
|
status: string,
|
|
@@ -189,8 +213,27 @@ async function bodyProblems(
|
|
|
189
213
|
const content = described.content ?? {};
|
|
190
214
|
const mediaTypes = Object.keys(content);
|
|
191
215
|
|
|
216
|
+
const mediaType =
|
|
217
|
+
(response.headers.get("content-type") ?? "")
|
|
218
|
+
.split(";")[0]
|
|
219
|
+
?.trim()
|
|
220
|
+
.toLowerCase() ?? "";
|
|
221
|
+
|
|
222
|
+
const key = mediaType === "" ? undefined : keyFor(mediaTypes, mediaType);
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* An empty export, a file of no bytes, a stream that yielded nothing:
|
|
226
|
+
* empty is content of every type but JSON, which has no empty value. An
|
|
227
|
+
* empty body may carry no type at all, and still be one a status of
|
|
228
|
+
* another type than JSON describes.
|
|
229
|
+
*/
|
|
192
230
|
if (text === "") {
|
|
193
|
-
|
|
231
|
+
const empty =
|
|
232
|
+
mediaTypes.length === 0 ||
|
|
233
|
+
(key !== undefined && !parsesAsJson(key)) ||
|
|
234
|
+
(mediaType === "" && mediaTypes.some((each) => !parsesAsJson(each)));
|
|
235
|
+
|
|
236
|
+
return empty
|
|
194
237
|
? []
|
|
195
238
|
: [
|
|
196
239
|
`an empty body, where its ${status} describes ${mediaTypes.join(", ")}`,
|
|
@@ -201,19 +244,15 @@ async function bodyProblems(
|
|
|
201
244
|
return [`a body, where its ${status} describes none`];
|
|
202
245
|
}
|
|
203
246
|
|
|
204
|
-
|
|
205
|
-
.split(";")[0]
|
|
206
|
-
?.trim();
|
|
207
|
-
|
|
208
|
-
if (mediaType === undefined || !mediaTypes.includes(mediaType)) {
|
|
247
|
+
if (key === undefined) {
|
|
209
248
|
return [
|
|
210
|
-
`a body of type "${mediaType
|
|
249
|
+
`a body of type "${mediaType}", where its ${status} describes ${mediaTypes.join(", ")}`,
|
|
211
250
|
];
|
|
212
251
|
}
|
|
213
252
|
|
|
214
|
-
const schema = content[
|
|
253
|
+
const schema = content[key]?.schema;
|
|
215
254
|
|
|
216
|
-
if (schema === undefined || !
|
|
255
|
+
if (schema === undefined || !parsesAsJson(key)) {
|
|
217
256
|
return [];
|
|
218
257
|
}
|
|
219
258
|
|