@tetsujs/openapi 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/operation.ts CHANGED
@@ -34,7 +34,8 @@ import type {
34
34
  import type { JsonSchemaObject } from "./emit.ts";
35
35
  import { emitted } from "./emit.ts";
36
36
  import type { Envelopes } from "./envelopes.ts";
37
- import { branchesOf, envelopeCode } from "./envelopes.ts";
37
+ import { branchesOf } from "./envelopes.ts";
38
+ import type { JsonSchema } from "./json-schema.ts";
38
39
  import { partParameters, pathParameters } from "./parameters.ts";
39
40
 
40
41
  /** Describes one route. */
@@ -103,11 +104,13 @@ export function declareEnvelopes(
103
104
  const described =
104
105
  schema === null ? undefined : emitted(schema, "", () => {});
105
106
 
106
- for (const branch of described ? branchesOf(described) : []) {
107
- const code = envelopeCode(branch);
107
+ const code = envelopes.format.code;
108
108
 
109
- if (code !== undefined) {
110
- envelopes.declare(Number(status), code, branch);
109
+ for (const branch of described ? branchesOf(described, code) : []) {
110
+ const found = code(branch);
111
+
112
+ if (found !== undefined) {
113
+ envelopes.declare(Number(status), found, branch);
111
114
  }
112
115
  }
113
116
  }
@@ -142,8 +145,8 @@ function branchesFor(
142
145
  envelopes: Envelopes,
143
146
  warn: (message: string) => void,
144
147
  ): Record<string, unknown>[] {
145
- return branchesOf(schema).map((branch) => {
146
- const code = envelopeCode(branch);
148
+ return branchesOf(schema, envelopes.format.code).map((branch) => {
149
+ const code = envelopes.format.code(branch);
147
150
 
148
151
  return code === undefined
149
152
  ? branch
@@ -430,20 +433,16 @@ function responses(
430
433
  schema === null ? undefined : emitted(schema, "a response", warn);
431
434
 
432
435
  add(status, {
433
- description: describeStatus(status),
434
- placeholder: true,
436
+ placeholder: describeStatus(status),
437
+ said: described ? wordsOf(described, envelopes) : [],
435
438
  schemas: described
436
- ? branchesFor(described, Number(status), envelopes, warn)
439
+ ? branchesFor(unionOnly(described), Number(status), envelopes, warn)
437
440
  : [],
438
441
  });
439
442
  }
440
443
 
441
444
  if (![...answers.keys()].some((status) => status.startsWith("2"))) {
442
- add("200", {
443
- description: "Successful response",
444
- placeholder: true,
445
- schemas: [],
446
- });
445
+ add("200", { placeholder: "Successful response", said: [], schemas: [] });
447
446
  }
448
447
 
449
448
  for (const [status, answer] of failures(
@@ -492,7 +491,12 @@ function failures(
492
491
  message?: string,
493
492
  fields?: DocumentedResponse["fields"],
494
493
  ): Record<string, unknown> => {
495
- const described = envelope(status, error, message, fields);
494
+ const described = envelopes.format.describe({
495
+ status,
496
+ ...(error === undefined ? {} : { error }),
497
+ ...(message === undefined ? {} : { message }),
498
+ fields: fields ?? {},
499
+ });
496
500
 
497
501
  const ref =
498
502
  error === undefined
@@ -506,12 +510,22 @@ function failures(
506
510
  found.push([
507
511
  options.validationStatus,
508
512
  {
509
- description: "Request failed schema validation",
513
+ said: [
514
+ {
515
+ code: "VALIDATION_FAILED",
516
+ text: "Request failed schema validation",
517
+ },
518
+ ],
510
519
  schemas: [
511
520
  envelopes.ref(
512
521
  options.validationStatus,
513
522
  "VALIDATION_FAILED",
514
- validationFailed(options.validationStatus),
523
+ envelopes.format.describe({
524
+ status: options.validationStatus,
525
+ error: "VALIDATION_FAILED",
526
+ message: "Validation failed",
527
+ fields: { issues },
528
+ }),
515
529
  warn,
516
530
  ) as unknown as Record<string, unknown>,
517
531
  ],
@@ -525,9 +539,13 @@ function failures(
525
539
  found.push([
526
540
  status,
527
541
  {
528
- description:
529
- requirement.description ??
530
- `Request did not satisfy ${requirement.name}`,
542
+ said: [
543
+ said(
544
+ requirement.error,
545
+ requirement.description ??
546
+ `Request did not satisfy ${requirement.name}`,
547
+ ),
548
+ ],
531
549
  schemas: [envelopeRef(status, requirement.error, requirement.message)],
532
550
  },
533
551
  ]);
@@ -541,7 +559,7 @@ function failures(
541
559
  found.push([
542
560
  response.status,
543
561
  {
544
- description: response.description,
562
+ said: [said(response.error, response.description)],
545
563
  schemas: described
546
564
  ? branchesFor(described, response.status, envelopes, warn)
547
565
  : [
@@ -564,7 +582,12 @@ function failures(
564
582
  found.push([
565
583
  400,
566
584
  {
567
- description: "Body could not be parsed in the declared shape",
585
+ said: [
586
+ said(
587
+ unparsable.error,
588
+ "Body could not be parsed in the declared shape",
589
+ ),
590
+ ],
568
591
  schemas: [envelopeRef(400, unparsable.error, unparsable.message)],
569
592
  },
570
593
  ]);
@@ -573,7 +596,9 @@ function failures(
573
596
  found.push([
574
597
  413,
575
598
  {
576
- description: "Body exceeded the configured size limit",
599
+ said: [
600
+ said("BODY_TOO_LARGE", "Body exceeded the configured size limit"),
601
+ ],
577
602
  schemas: [
578
603
  envelopeRef(
579
604
  413,
@@ -588,7 +613,12 @@ function failures(
588
613
  found.push([
589
614
  500,
590
615
  {
591
- description: "The request failed and nothing mapped the failure",
616
+ said: [
617
+ said(
618
+ "INTERNAL_SERVER_ERROR",
619
+ "The request failed and nothing mapped the failure",
620
+ ),
621
+ ],
592
622
  schemas: [
593
623
  envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
594
624
  ],
@@ -606,17 +636,66 @@ function failures(
606
636
  * reference to its definition; empty when the answer has no body to
607
637
  * describe.
608
638
  *
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.
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.
612
644
  */
613
645
  interface Answer {
614
- readonly description: string;
615
- readonly placeholder?: boolean;
646
+ readonly said: readonly Said[];
647
+ readonly placeholder?: string;
616
648
  readonly schemas: readonly Record<string, unknown>[];
617
649
  readonly headers?: Readonly<Record<string, HeaderObject>>;
618
650
  }
619
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
+
620
699
  /**
621
700
  * Folds everything that answers with one status into one response.
622
701
  *
@@ -633,12 +712,10 @@ interface Answer {
633
712
  * alternative, not two.
634
713
  *
635
714
  * 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.
715
+ * standing for all of them; descriptions as {@link wording} words them.
638
716
  */
639
717
  function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
640
718
  const schemas = distinct(list.flatMap((answer) => answer.schemas));
641
- const worded = list.filter((answer) => !answer.placeholder);
642
719
  const headers: Record<string, HeaderObject> = {};
643
720
 
644
721
  for (const answer of list) {
@@ -648,9 +725,7 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
648
725
  }
649
726
 
650
727
  return {
651
- description: (worded.length > 0 ? worded : list)
652
- .map((answer) => answer.description)
653
- .join("; "),
728
+ description: wording(list),
654
729
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
655
730
  ...(schemas.length > 0
656
731
  ? {
@@ -662,6 +737,49 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
662
737
  };
663
738
  }
664
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
+
665
783
  /**
666
784
  * The alternatives of a body as one schema.
667
785
  *
@@ -670,7 +788,8 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
670
788
  * builds a tagged union and narrows on the code, instead of trying the
671
789
  * shapes in turn. The mapping is spelled out, because without it OpenAPI
672
790
  * matches the value against the component's name — `Unauthorized`, where
673
- * the body says `UNAUTHORIZED`.
791
+ * the body says `UNAUTHORIZED`. The field is the error format's; a format
792
+ * that names none — its code nested below the top — has no discriminator.
674
793
  */
675
794
  function unionOf(
676
795
  schemas: readonly Record<string, unknown>[],
@@ -682,6 +801,12 @@ function unionOf(
682
801
  return only;
683
802
  }
684
803
 
804
+ const propertyName = envelopes.format.discriminator;
805
+
806
+ if (propertyName === undefined) {
807
+ return { anyOf: schemas };
808
+ }
809
+
685
810
  const mapping: Record<string, string> = {};
686
811
 
687
812
  for (const schema of schemas) {
@@ -697,7 +822,7 @@ function unionOf(
697
822
 
698
823
  return {
699
824
  anyOf: schemas,
700
- discriminator: { propertyName: "error", mapping },
825
+ discriminator: { propertyName, mapping },
701
826
  };
702
827
  }
703
828
 
@@ -730,65 +855,21 @@ function describeStatus(status: string): string {
730
855
  }
731
856
 
732
857
  /**
733
- * The body of one framework-produced failure, as JSON Schema.
734
- *
735
- * The status is a `const`, and so is the code whenever the framework knows
736
- * which one it will be: a generated client can then discriminate on
737
- * `error` instead of receiving "an object with three strings", and a
738
- * reader of the document sees `"BODY_TOO_LARGE"` rather than `string`.
739
- *
740
- * The message is documented by example, never as a `const` — it is wording
741
- * meant for a human, the one field a client must not match on, and the one
742
- * this project reserves the right to reword.
743
- *
744
- * Fields a hook adds come after the three, and never in their place.
858
+ * What a validation failure carries besides the code and the message: every
859
+ * issue, with where it is. Handed to the error format as a field, for the
860
+ * format to place.
745
861
  */
746
- function envelope(
747
- status: number,
748
- error?: string,
749
- message?: string,
750
- fields: DocumentedResponse["fields"] = {},
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
-
762
- return {
862
+ const issues: JsonSchema = {
863
+ type: "array",
864
+ items: {
763
865
  type: "object",
764
- required: ["status", "message", "error", ...added],
765
- properties: { ...fields, ...own },
766
- };
767
- }
768
-
769
- /** The validation envelope: the standard three fields, plus the issues. */
770
- function validationFailed(status: number): Record<string, unknown> {
771
- const base = envelope(status, "VALIDATION_FAILED", "Validation failed");
772
-
773
- return {
774
- ...base,
775
- required: ["status", "message", "error", "issues"],
866
+ required: ["message", "path"],
776
867
  properties: {
777
- ...(base.properties as Record<string, unknown>),
778
- issues: {
779
- type: "array",
780
- items: {
781
- type: "object",
782
- required: ["message", "path"],
783
- properties: {
784
- message: { type: "string" },
785
- path: { type: "array", items: { type: ["string", "number"] } },
786
- },
787
- },
788
- },
868
+ message: { type: "string" },
869
+ path: { type: "array", items: { type: ["string", "number"] } },
789
870
  },
790
- };
791
- }
871
+ },
872
+ };
792
873
 
793
874
  /**
794
875
  * How a body of this type fails to parse — or nothing, for a text or a