@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/README.md +94 -0
- package/dist/src/controller.d.ts +6 -0
- package/dist/src/controller.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/envelopes.d.ts +9 -11
- package/dist/src/envelopes.d.ts.map +1 -1
- package/dist/src/errors.d.ts +74 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/index.d.ts +28 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +186 -86
- package/dist/src/index.js.map +9 -8
- package/dist/src/operation.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/controller.ts +8 -0
- package/src/emit.ts +1 -0
- package/src/envelopes.ts +84 -58
- package/src/errors.ts +177 -0
- package/src/index.ts +34 -1
- package/src/operation.ts +174 -93
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
|
|
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
|
-
|
|
107
|
-
const code = envelopeCode(branch);
|
|
107
|
+
const code = envelopes.format.code;
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
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
|
-
|
|
434
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
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
|
|
615
|
-
readonly placeholder?:
|
|
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
|
|
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: (
|
|
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
|
|
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
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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: ["
|
|
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
|
-
|
|
778
|
-
|
|
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
|