@tetsujs/openapi 0.4.1 → 0.4.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * JSON Schema 2020-12, as written by hand.
3
+ *
4
+ * Every other schema in a document comes from a validator, which describes
5
+ * itself. What a hook adds to its answer — a field next to the envelope, a
6
+ * header — has no validator behind it, and is written as JSON Schema
7
+ * directly. Typed closed, keyword by keyword, so that an editor offers the
8
+ * keywords and a misspelled one is an error: an open record would take
9
+ * `minimun` and `{ type: "int" }` and put them in the document as they are.
10
+ *
11
+ * Closed on purpose, not by omission. The generator asks every schema for
12
+ * the 2020-12 dialect, and a dialect's keywords are a fixed list. The one
13
+ * open door is what OpenAPI itself allows in a schema — extensions, named
14
+ * `x-` — and no misspelling of a keyword starts with it.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ /** A primitive type name of JSON Schema. */
20
+ export type JsonSchemaType =
21
+ | "string"
22
+ | "number"
23
+ | "integer"
24
+ | "boolean"
25
+ | "object"
26
+ | "array"
27
+ | "null";
28
+
29
+ /**
30
+ * A schema: an object of keywords, or `true` and `false` — the schemas
31
+ * everything and nothing satisfy.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const retryAfter: JsonSchema = { type: "integer", minimum: 0 };
36
+ * ```
37
+ */
38
+ export type JsonSchema = JsonSchemaKeywords | boolean;
39
+
40
+ /** The keywords of JSON Schema 2020-12. */
41
+ export interface JsonSchemaKeywords {
42
+ readonly $schema?: string;
43
+ readonly $id?: string;
44
+ readonly $ref?: string;
45
+ readonly $anchor?: string;
46
+ readonly $dynamicRef?: string;
47
+ readonly $dynamicAnchor?: string;
48
+ readonly $vocabulary?: Readonly<Record<string, boolean>>;
49
+ readonly $comment?: string;
50
+ readonly $defs?: Readonly<Record<string, JsonSchema>>;
51
+
52
+ readonly type?: JsonSchemaType | readonly JsonSchemaType[];
53
+ readonly enum?: readonly unknown[];
54
+ readonly const?: unknown;
55
+
56
+ readonly multipleOf?: number;
57
+ readonly maximum?: number;
58
+ readonly exclusiveMaximum?: number;
59
+ readonly minimum?: number;
60
+ readonly exclusiveMinimum?: number;
61
+
62
+ readonly maxLength?: number;
63
+ readonly minLength?: number;
64
+ readonly pattern?: string;
65
+ readonly format?: string;
66
+ readonly contentEncoding?: string;
67
+ readonly contentMediaType?: string;
68
+ readonly contentSchema?: JsonSchema;
69
+
70
+ readonly prefixItems?: readonly JsonSchema[];
71
+ readonly items?: JsonSchema;
72
+ readonly contains?: JsonSchema;
73
+ readonly maxItems?: number;
74
+ readonly minItems?: number;
75
+ readonly uniqueItems?: boolean;
76
+ readonly maxContains?: number;
77
+ readonly minContains?: number;
78
+ readonly unevaluatedItems?: JsonSchema;
79
+
80
+ readonly properties?: Readonly<Record<string, JsonSchema>>;
81
+ readonly patternProperties?: Readonly<Record<string, JsonSchema>>;
82
+ readonly additionalProperties?: JsonSchema;
83
+ readonly unevaluatedProperties?: JsonSchema;
84
+ readonly propertyNames?: JsonSchema;
85
+ readonly required?: readonly string[];
86
+ readonly dependentRequired?: Readonly<Record<string, readonly string[]>>;
87
+ readonly dependentSchemas?: Readonly<Record<string, JsonSchema>>;
88
+ readonly maxProperties?: number;
89
+ readonly minProperties?: number;
90
+
91
+ readonly allOf?: readonly JsonSchema[];
92
+ readonly anyOf?: readonly JsonSchema[];
93
+ readonly oneOf?: readonly JsonSchema[];
94
+ readonly not?: JsonSchema;
95
+ readonly if?: JsonSchema;
96
+ readonly then?: JsonSchema;
97
+ readonly else?: JsonSchema;
98
+
99
+ readonly title?: string;
100
+ readonly description?: string;
101
+ readonly default?: unknown;
102
+ readonly deprecated?: boolean;
103
+ readonly readOnly?: boolean;
104
+ readonly writeOnly?: boolean;
105
+ readonly examples?: readonly unknown[];
106
+
107
+ readonly [extension: `x-${string}`]: unknown;
108
+ }
package/src/operation.ts CHANGED
@@ -16,17 +16,25 @@ import type {
16
16
  BodyType,
17
17
  RouteTableEntry,
18
18
  } from "@tetsujs/core";
19
- import type { HookContributions, SecurityRequirement } from "./annotations.ts";
19
+ import { errorBody } from "@tetsujs/core";
20
+ import type {
21
+ DocumentedResponse,
22
+ HookContributions,
23
+ SecurityRequirement,
24
+ } from "./annotations.ts";
20
25
  import { contributionsOf } from "./annotations.ts";
21
26
  import type { SchemaComponents } from "./components.ts";
22
27
  import { failureName } from "./components.ts";
23
28
  import type {
24
29
  ContentMap,
30
+ HeaderObject,
25
31
  OperationObject,
26
32
  ResponseObject,
27
33
  } from "./document.ts";
28
34
  import type { JsonSchemaObject } from "./emit.ts";
29
35
  import { emitted } from "./emit.ts";
36
+ import type { Envelopes } from "./envelopes.ts";
37
+ import { branchesOf, envelopeCode } from "./envelopes.ts";
30
38
  import { partParameters, pathParameters } from "./parameters.ts";
31
39
 
32
40
  /** Describes one route. */
@@ -34,6 +42,7 @@ export function operationOf(
34
42
  entry: RouteTableEntry,
35
43
  options: AppOptions,
36
44
  components: SchemaComponents,
45
+ envelopes: Envelopes,
37
46
  ids: OperationIds,
38
47
  warn: (message: string) => void,
39
48
  ): OperationObject {
@@ -68,10 +77,83 @@ export function operationOf(
68
77
  ...(contributed.security.length > 0
69
78
  ? { security: securityOf(contributed.conditions) }
70
79
  : {}),
71
- responses: responses(entry, options, contributed, components, warn),
80
+ responses: responses(
81
+ entry,
82
+ options,
83
+ contributed,
84
+ components,
85
+ envelopes,
86
+ warn,
87
+ ),
72
88
  };
73
89
  }
74
90
 
91
+ /**
92
+ * Records the envelopes a route declares, so that its definitions are the
93
+ * ones the document keeps — whichever operation refers to them first.
94
+ *
95
+ * Runs before any operation is built. A schema that cannot describe itself
96
+ * is skipped without a word: building the operation says so, once.
97
+ */
98
+ export function declareEnvelopes(
99
+ entry: RouteTableEntry,
100
+ envelopes: Envelopes,
101
+ ): void {
102
+ for (const [status, schema] of declaredResponses(entry)) {
103
+ const described =
104
+ schema === null ? undefined : emitted(schema, "", () => {});
105
+
106
+ for (const branch of described ? branchesOf(described) : []) {
107
+ const code = envelopeCode(branch);
108
+
109
+ if (code !== undefined) {
110
+ envelopes.declare(Number(status), code, branch);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ /** The route's response map as pairs; a single schema is its `200`. */
117
+ function declaredResponses(
118
+ entry: RouteTableEntry,
119
+ ): [status: string, schema: AnySchema | null][] {
120
+ const response = entry.def.schema?.response;
121
+
122
+ if (!response) {
123
+ return [];
124
+ }
125
+
126
+ if ("~standard" in response) {
127
+ return [["200", response as AnySchema]];
128
+ }
129
+
130
+ return Object.entries(response) as [string, AnySchema | null][];
131
+ }
132
+
133
+ /**
134
+ * A described body as the alternatives the document lists: every envelope
135
+ * in it a reference to the one definition of its status and code, and a
136
+ * union taken apart into its branches, so that it joins the other answers
137
+ * of its status rather than nesting inside them.
138
+ */
139
+ function branchesFor(
140
+ schema: JsonSchemaObject,
141
+ status: number,
142
+ envelopes: Envelopes,
143
+ warn: (message: string) => void,
144
+ ): Record<string, unknown>[] {
145
+ return branchesOf(schema).map((branch) => {
146
+ const code = envelopeCode(branch);
147
+
148
+ return code === undefined
149
+ ? branch
150
+ : (envelopes.ref(status, code, branch, warn) as unknown as Record<
151
+ string,
152
+ unknown
153
+ >);
154
+ });
155
+ }
156
+
75
157
  /**
76
158
  * The security a route asks for, as OpenAPI spells it.
77
159
  *
@@ -334,90 +416,121 @@ function responses(
334
416
  options: AppOptions,
335
417
  contributed: HookContributions,
336
418
  components: SchemaComponents,
419
+ envelopes: Envelopes,
337
420
  warn: (message: string) => void,
338
421
  ): Record<string, ResponseObject> {
339
- const declared: Record<string, ResponseObject> = {};
340
- const response = entry.def.schema?.response;
422
+ const answers = new Map<string, Answer[]>();
341
423
 
342
- if (response && "~standard" in response) {
343
- declared["200"] = jsonResponse(
344
- response as AnySchema,
345
- "Successful response",
346
- warn,
347
- );
348
- } else if (response) {
349
- for (const [status, schema] of Object.entries(response)) {
350
- declared[status] =
351
- schema === null
352
- ? { description: describeStatus(status) }
353
- : jsonResponse(schema, describeStatus(status), warn);
354
- }
424
+ const add = (status: string, answer: Answer): void => {
425
+ answers.set(status, [...(answers.get(status) ?? []), answer]);
426
+ };
427
+
428
+ for (const [status, schema] of declaredResponses(entry)) {
429
+ const described =
430
+ schema === null ? undefined : emitted(schema, "a response", warn);
431
+
432
+ add(status, {
433
+ description: describeStatus(status),
434
+ placeholder: true,
435
+ schemas: described
436
+ ? branchesFor(described, Number(status), envelopes, warn)
437
+ : [],
438
+ });
355
439
  }
356
440
 
357
- if (!declared["200"] && !hasSuccess(declared)) {
358
- declared["200"] = { description: "Successful response" };
441
+ if (![...answers.keys()].some((status) => status.startsWith("2"))) {
442
+ add("200", {
443
+ description: "Successful response",
444
+ placeholder: true,
445
+ schemas: [],
446
+ });
359
447
  }
360
448
 
361
- return {
362
- ...declared,
363
- ...frameworkFailures(
364
- entry,
365
- options,
366
- contributed,
367
- declared,
368
- components,
369
- warn,
370
- ),
371
- };
449
+ for (const [status, answer] of failures(
450
+ entry,
451
+ options,
452
+ contributed,
453
+ components,
454
+ envelopes,
455
+ warn,
456
+ )) {
457
+ add(String(status), answer);
458
+ }
459
+
460
+ const described: Record<string, ResponseObject> = {};
461
+
462
+ for (const [status, list] of answers) {
463
+ described[status] = merge(list, envelopes);
464
+ }
465
+
466
+ return described;
372
467
  }
373
468
 
374
- function frameworkFailures(
469
+ /**
470
+ * Everything the route can answer with that its own schema does not say:
471
+ * what its hooks declare, and what the framework itself answers.
472
+ */
473
+ function failures(
375
474
  entry: RouteTableEntry,
376
475
  options: AppOptions,
377
476
  contributed: HookContributions,
378
- declared: Record<string, ResponseObject>,
379
477
  components: SchemaComponents,
478
+ envelopes: Envelopes,
380
479
  warn: (message: string) => void,
381
- ): Record<string, ResponseObject> {
480
+ ): [status: number, answer: Answer][] {
382
481
  const schema = entry.def.schema;
383
- const failures = new Map<string, Failure[]>();
384
-
385
- const add = (status: number, failure: Failure): void => {
386
- const key = String(status);
387
-
388
- failures.set(key, [...(failures.get(key) ?? []), failure]);
389
- };
482
+ const found: [number, Answer][] = [];
390
483
 
391
- /** Defines the envelope of one failure once, and refers to it. */
484
+ /**
485
+ * Refers to the definition of one failure: the one of its status and
486
+ * code, or — for a failure whose code only the hook knows and did not
487
+ * declare — one of its own, named after the status.
488
+ */
392
489
  const envelopeRef = (
393
490
  status: number,
394
491
  error?: string,
395
492
  message?: string,
396
- ): Record<string, unknown> =>
397
- components.ref(
398
- failureName(status, error),
399
- envelope(status, error, message),
400
- ) as unknown as Record<string, unknown>;
493
+ fields?: DocumentedResponse["fields"],
494
+ ): Record<string, unknown> => {
495
+ const described = envelope(status, error, message, fields);
496
+
497
+ const ref =
498
+ error === undefined
499
+ ? components.ref(failureName(status), described)
500
+ : envelopes.ref(status, error, described, warn);
501
+
502
+ return ref as unknown as Record<string, unknown>;
503
+ };
401
504
 
402
505
  if (schema?.params || schema?.query || schema?.headers || schema?.body) {
403
- add(options.validationStatus, {
404
- description: "Request failed schema validation",
405
- schema: components.ref(
406
- failureName(options.validationStatus, "VALIDATION_FAILED"),
407
- validationFailed(options.validationStatus),
408
- ) as unknown as Record<string, unknown>,
409
- });
506
+ found.push([
507
+ options.validationStatus,
508
+ {
509
+ description: "Request failed schema validation",
510
+ schemas: [
511
+ envelopes.ref(
512
+ options.validationStatus,
513
+ "VALIDATION_FAILED",
514
+ validationFailed(options.validationStatus),
515
+ warn,
516
+ ) as unknown as Record<string, unknown>,
517
+ ],
518
+ },
519
+ ]);
410
520
  }
411
521
 
412
522
  for (const requirement of contributed.security) {
413
523
  const status = requirement.status ?? 401;
414
524
 
415
- add(status, {
416
- description:
417
- requirement.description ??
418
- `Request did not satisfy ${requirement.name}`,
419
- schema: envelopeRef(status, requirement.error, requirement.message),
420
- });
525
+ found.push([
526
+ status,
527
+ {
528
+ description:
529
+ requirement.description ??
530
+ `Request did not satisfy ${requirement.name}`,
531
+ schemas: [envelopeRef(status, requirement.error, requirement.message)],
532
+ },
533
+ ]);
421
534
  }
422
535
 
423
536
  for (const response of contributed.responses) {
@@ -425,52 +538,83 @@ function frameworkFailures(
425
538
  ? emitted(response.schema, "a hook's response", warn)
426
539
  : undefined;
427
540
 
428
- add(response.status, {
429
- description: response.description,
430
- schema:
431
- described ??
432
- envelopeRef(response.status, response.error, response.message),
433
- });
541
+ found.push([
542
+ response.status,
543
+ {
544
+ description: response.description,
545
+ schemas: described
546
+ ? branchesFor(described, response.status, envelopes, warn)
547
+ : [
548
+ envelopeRef(
549
+ response.status,
550
+ response.error,
551
+ response.message,
552
+ response.fields,
553
+ ),
554
+ ],
555
+ ...(response.headers ? { headers: response.headers } : {}),
556
+ },
557
+ ]);
434
558
  }
435
559
 
436
560
  if (schema?.body || entry.def.bodyType) {
437
561
  const unparsable = parseFailure(entry.def.bodyType);
438
562
 
439
563
  if (unparsable) {
440
- add(400, {
441
- description: "Body could not be parsed in the declared shape",
442
- schema: envelopeRef(400, unparsable.error, unparsable.message),
443
- });
564
+ found.push([
565
+ 400,
566
+ {
567
+ description: "Body could not be parsed in the declared shape",
568
+ schemas: [envelopeRef(400, unparsable.error, unparsable.message)],
569
+ },
570
+ ]);
444
571
  }
445
572
 
446
- add(413, {
447
- description: "Body exceeded the configured size limit",
448
- schema: envelopeRef(
449
- 413,
450
- "BODY_TOO_LARGE",
451
- "Body exceeds the configured limit",
452
- ),
453
- });
573
+ found.push([
574
+ 413,
575
+ {
576
+ description: "Body exceeded the configured size limit",
577
+ schemas: [
578
+ envelopeRef(
579
+ 413,
580
+ "BODY_TOO_LARGE",
581
+ "Body exceeds the configured limit",
582
+ ),
583
+ ],
584
+ },
585
+ ]);
454
586
  }
455
587
 
456
- add(500, {
457
- description: "The request failed and nothing mapped the failure",
458
- schema: envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
459
- });
460
-
461
- const responses: Record<string, ResponseObject> = {};
462
-
463
- for (const [status, list] of failures) {
464
- responses[status] = merge(declared[status], list);
465
- }
588
+ found.push([
589
+ 500,
590
+ {
591
+ description: "The request failed and nothing mapped the failure",
592
+ schemas: [
593
+ envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
594
+ ],
595
+ },
596
+ ]);
466
597
 
467
- return responses;
598
+ return found;
468
599
  }
469
600
 
470
- /** One way the framework itself can answer. */
471
- interface Failure {
601
+ /**
602
+ * One way a route can answer with a status: what the route declared, what
603
+ * a hook declared, or what the framework answers by itself.
604
+ *
605
+ * `schemas` are the alternatives of the body, each envelope already a
606
+ * reference to its definition; empty when the answer has no body to
607
+ * describe.
608
+ *
609
+ * A route declares a status by its schema alone and has no words for it,
610
+ * so its description is a `placeholder`: it stands for the status only
611
+ * when nothing else describes it.
612
+ */
613
+ interface Answer {
472
614
  readonly description: string;
473
- readonly schema: Record<string, unknown>;
615
+ readonly placeholder?: boolean;
616
+ readonly schemas: readonly Record<string, unknown>[];
617
+ readonly headers?: Readonly<Record<string, HeaderObject>>;
474
618
  }
475
619
 
476
620
  /**
@@ -487,41 +631,74 @@ interface Failure {
487
631
  * the answer the endpoint is about, and the failures are what can happen
488
632
  * to it. Identical bodies are folded together — a `$ref` repeated is one
489
633
  * alternative, not two.
634
+ *
635
+ * Headers are gathered the same way, the first description of a name
636
+ * standing for all of them. Descriptions are joined, except a route's
637
+ * placeholder next to one that says something.
490
638
  */
491
- function merge(
492
- declared: ResponseObject | undefined,
493
- list: readonly Failure[],
494
- ): ResponseObject {
495
- const descriptions = [
496
- ...(declared ? [declared.description] : []),
497
- ...list.map((failure) => failure.description),
498
- ];
499
-
500
- const schemas = distinct([
501
- ...declaredSchemas(declared),
502
- ...list.map((failure) => failure.schema),
503
- ]);
504
-
505
- const [only] = schemas;
639
+ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
640
+ const schemas = distinct(list.flatMap((answer) => answer.schemas));
641
+ const worded = list.filter((answer) => !answer.placeholder);
642
+ const headers: Record<string, HeaderObject> = {};
643
+
644
+ for (const answer of list) {
645
+ for (const [name, header] of Object.entries(answer.headers ?? {})) {
646
+ headers[name] ??= header;
647
+ }
648
+ }
506
649
 
507
650
  return {
508
- description: descriptions.join("; "),
509
- ...(schemas.length === 1 && only
510
- ? { content: { "application/json": { schema: only } } }
511
- : { content: { "application/json": { schema: { anyOf: schemas } } } }),
651
+ description: (worded.length > 0 ? worded : list)
652
+ .map((answer) => answer.description)
653
+ .join("; "),
654
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
655
+ ...(schemas.length > 0
656
+ ? {
657
+ content: {
658
+ "application/json": { schema: unionOf(schemas, envelopes) },
659
+ },
660
+ }
661
+ : {}),
512
662
  };
513
663
  }
514
664
 
515
665
  /**
516
- * The body a declared response describes, if it describes one: a response
517
- * whose schema could not be emitted contributes its description alone.
666
+ * The alternatives of a body as one schema.
667
+ *
668
+ * When every alternative is an envelope, each with its own code, the union
669
+ * says so with a `discriminator` on `error`: a generated client then
670
+ * builds a tagged union and narrows on the code, instead of trying the
671
+ * shapes in turn. The mapping is spelled out, because without it OpenAPI
672
+ * matches the value against the component's name — `Unauthorized`, where
673
+ * the body says `UNAUTHORIZED`.
518
674
  */
519
- function declaredSchemas(
520
- declared: ResponseObject | undefined,
521
- ): Record<string, unknown>[] {
522
- const schema = declared?.content?.["application/json"]?.schema;
675
+ function unionOf(
676
+ schemas: readonly Record<string, unknown>[],
677
+ envelopes: Envelopes,
678
+ ): Record<string, unknown> {
679
+ const [only] = schemas;
680
+
681
+ if (schemas.length === 1 && only) {
682
+ return only;
683
+ }
684
+
685
+ const mapping: Record<string, string> = {};
523
686
 
524
- return schema ? [schema] : [];
687
+ for (const schema of schemas) {
688
+ const ref = (schema as { $ref?: unknown }).$ref;
689
+ const code = typeof ref === "string" ? envelopes.codeOf(ref) : undefined;
690
+
691
+ if (typeof ref !== "string" || code === undefined) {
692
+ return { anyOf: schemas };
693
+ }
694
+
695
+ mapping[code] = ref;
696
+ }
697
+
698
+ return {
699
+ anyOf: schemas,
700
+ discriminator: { propertyName: "error", mapping },
701
+ };
525
702
  }
526
703
 
527
704
  function distinct(
@@ -542,33 +719,14 @@ function distinct(
542
719
  });
543
720
  }
544
721
 
545
- function jsonResponse(
546
- schema: unknown,
547
- description: string,
548
- warn: (message: string) => void,
549
- ): ResponseObject {
550
- const described = emitted(schema, "a response", warn);
551
-
552
- if (!described) {
553
- return { description };
554
- }
555
-
556
- return {
557
- description,
558
- content: { "application/json": { schema: described } },
559
- };
560
- }
561
-
562
- function hasSuccess(declared: Record<string, ResponseObject>): boolean {
563
- return Object.keys(declared).some((status) => status.startsWith("2"));
564
- }
565
-
566
722
  function describeStatus(status: string): string {
567
723
  if (status === "204") {
568
724
  return "No content";
569
725
  }
570
726
 
571
- return status.startsWith("2") ? "Successful response" : `Response ${status}`;
727
+ return status.startsWith("2")
728
+ ? "Successful response"
729
+ : errorBody(Number(status)).message;
572
730
  }
573
731
 
574
732
  /**
@@ -582,22 +740,29 @@ function describeStatus(status: string): string {
582
740
  * The message is documented by example, never as a `const` — it is wording
583
741
  * meant for a human, the one field a client must not match on, and the one
584
742
  * this project reserves the right to reword.
743
+ *
744
+ * Fields a hook adds come after the three, and never in their place.
585
745
  */
586
746
  function envelope(
587
747
  status: number,
588
748
  error?: string,
589
749
  message?: string,
750
+ fields: DocumentedResponse["fields"] = {},
590
751
  ): JsonSchemaObject {
752
+ const own = {
753
+ status: { type: "integer", const: status },
754
+ message: message
755
+ ? { type: "string", examples: [message] }
756
+ : { type: "string" },
757
+ error: error ? { type: "string", const: error } : { type: "string" },
758
+ };
759
+
760
+ const added = Object.keys(fields).filter((name) => !Object.hasOwn(own, name));
761
+
591
762
  return {
592
763
  type: "object",
593
- required: ["status", "message", "error"],
594
- properties: {
595
- status: { type: "integer", const: status },
596
- message: message
597
- ? { type: "string", examples: [message] }
598
- : { type: "string" },
599
- error: error ? { type: "string", const: error } : { type: "string" },
600
- },
764
+ required: ["status", "message", "error", ...added],
765
+ properties: { ...fields, ...own },
601
766
  };
602
767
  }
603
768