@tetsujs/openapi 0.6.2 → 0.6.3

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/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
- for (const [status, { body }] of declaredResponses(entry)) {
105
- const described = body === null ? undefined : emitted(body, "", () => {});
108
+ const own: [status: number, body: AnySchema][] = [];
106
109
 
107
- const code = envelopes.format.code;
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(Number(status), found, branch);
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: described
575
- ? branchesFor(unionOnly(described), Number(status), envelopes, warn)
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: [envelopeRef(status, requirement.error, requirement.message)],
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
- said: [said(response.error, response.description)],
712
- schemas: described
713
- ? branchesFor(described, response.status, envelopes, warn)
714
- : [
715
- envelopeRef(
716
- response.status,
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
- ...(schemas.length > 0
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. With nothing said at all, the route's placeholder stands.
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 [only] = items;
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 (items.length === 1 && only) {
1069
+ if (said.length === 1 && only) {
916
1070
  return only.text;
917
1071
  }
918
1072
 
919
- if (items.length > 1) {
920
- return items
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
- return mediaTypes.length === 0
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
- const mediaType = (response.headers.get("content-type") ?? "")
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 ?? ""}", where its ${status} describes ${mediaTypes.join(", ")}`,
249
+ `a body of type "${mediaType}", where its ${status} describes ${mediaTypes.join(", ")}`,
211
250
  ];
212
251
  }
213
252
 
214
- const schema = content[mediaType]?.schema;
253
+ const schema = content[key]?.schema;
215
254
 
216
- if (schema === undefined || !mediaType.endsWith("json")) {
255
+ if (schema === undefined || !parsesAsJson(key)) {
217
256
  return [];
218
257
  }
219
258