@tetsujs/openapi 0.4.2 → 0.5.0
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 +77 -0
- package/dist/src/controller.d.ts +6 -0
- package/dist/src/controller.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 +123 -70
- package/dist/src/index.js.map +8 -7
- package/dist/src/operation.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/controller.ts +8 -0
- package/src/envelopes.ts +84 -58
- package/src/errors.ts +177 -0
- package/src/index.ts +34 -1
- package/src/operation.ts +42 -66
package/src/envelopes.ts
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* read first, before any operation is built, so the winner does not depend
|
|
21
21
|
* on which route happens to come first in the table.
|
|
22
22
|
*
|
|
23
|
+
* What an envelope looks like — how one is described, where its code is,
|
|
24
|
+
* what a client discriminates on — is the document's error format, which
|
|
25
|
+
* the registry carries for everything that builds or reads one.
|
|
26
|
+
*
|
|
23
27
|
* Internal to the package.
|
|
24
28
|
*
|
|
25
29
|
* @module
|
|
@@ -28,9 +32,13 @@
|
|
|
28
32
|
import type { SchemaComponents, SchemaRef } from "./components.ts";
|
|
29
33
|
import { failureName } from "./components.ts";
|
|
30
34
|
import type { JsonSchemaObject } from "./emit.ts";
|
|
35
|
+
import type { ResolvedFormat } from "./errors.ts";
|
|
31
36
|
|
|
32
37
|
/** The definitions of every envelope of one document. */
|
|
33
38
|
export interface Envelopes {
|
|
39
|
+
/** The document's error format. */
|
|
40
|
+
readonly format: ResolvedFormat;
|
|
41
|
+
|
|
34
42
|
/**
|
|
35
43
|
* Records a route's definition, before anything is referenced. The first
|
|
36
44
|
* route to declare a status and code defines it.
|
|
@@ -59,7 +67,10 @@ export interface Envelopes {
|
|
|
59
67
|
codeOf(ref: string): string | undefined;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
export function envelopes(
|
|
70
|
+
export function envelopes(
|
|
71
|
+
components: SchemaComponents,
|
|
72
|
+
format: ResolvedFormat,
|
|
73
|
+
): Envelopes {
|
|
63
74
|
const declared = new Map<string, JsonSchemaObject>();
|
|
64
75
|
const defined = new Map<string, JsonSchemaObject>();
|
|
65
76
|
const refs = new Map<string, SchemaRef>();
|
|
@@ -68,6 +79,8 @@ export function envelopes(components: SchemaComponents): Envelopes {
|
|
|
68
79
|
const reported = new Set<string>();
|
|
69
80
|
|
|
70
81
|
return {
|
|
82
|
+
format,
|
|
83
|
+
|
|
71
84
|
declare(status, code, schema) {
|
|
72
85
|
const key = keyOf(status, code);
|
|
73
86
|
|
|
@@ -129,42 +142,6 @@ export function envelopes(components: SchemaComponents): Envelopes {
|
|
|
129
142
|
}
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
/**
|
|
133
|
-
* The error code of a schema that is an envelope: an object whose `error`
|
|
134
|
-
* is required and holds one string — a `const`, or an `enum` of one value.
|
|
135
|
-
*
|
|
136
|
-
* Read off what the validator emitted, not off a declaration: the route
|
|
137
|
-
* has no other way to say which of its responses is an envelope, and the
|
|
138
|
-
* shape is what a client would branch on anyway.
|
|
139
|
-
*/
|
|
140
|
-
export function envelopeCode(schema: JsonSchemaObject): string | undefined {
|
|
141
|
-
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
142
|
-
|
|
143
|
-
if (!required.includes("error")) {
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const error = (
|
|
148
|
-
schema.properties as
|
|
149
|
-
| { error?: { const?: unknown; enum?: unknown } }
|
|
150
|
-
| undefined
|
|
151
|
-
)?.error;
|
|
152
|
-
|
|
153
|
-
if (typeof error?.const === "string") {
|
|
154
|
-
return error.const;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
Array.isArray(error?.enum) &&
|
|
159
|
-
error.enum.length === 1 &&
|
|
160
|
-
typeof error.enum[0] === "string"
|
|
161
|
-
) {
|
|
162
|
-
return error.enum[0];
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return undefined;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
145
|
/**
|
|
169
146
|
* The alternatives a schema stands for, as a list the status's own union
|
|
170
147
|
* can take in.
|
|
@@ -177,20 +154,25 @@ export function envelopeCode(schema: JsonSchemaObject): string | undefined {
|
|
|
177
154
|
* beside its union, a `title` or a `$defs`, is left whole: those keywords
|
|
178
155
|
* belong to the union, and would be lost with it.
|
|
179
156
|
*/
|
|
180
|
-
export function branchesOf(
|
|
157
|
+
export function branchesOf(
|
|
158
|
+
schema: JsonSchemaObject,
|
|
159
|
+
code: (schema: JsonSchemaObject) => string | undefined,
|
|
160
|
+
): JsonSchemaObject[] {
|
|
181
161
|
const keys = Object.keys(schema);
|
|
182
162
|
|
|
183
163
|
if (keys.length !== 1) {
|
|
184
164
|
return [schema];
|
|
185
165
|
}
|
|
186
166
|
|
|
167
|
+
const within = (branch: JsonSchemaObject) => branchesOf(branch, code);
|
|
168
|
+
|
|
187
169
|
if (Array.isArray(schema.anyOf)) {
|
|
188
|
-
return (schema.anyOf as JsonSchemaObject[]).flatMap(
|
|
170
|
+
return (schema.anyOf as JsonSchemaObject[]).flatMap(within);
|
|
189
171
|
}
|
|
190
172
|
|
|
191
173
|
if (Array.isArray(schema.oneOf)) {
|
|
192
|
-
const branches = (schema.oneOf as JsonSchemaObject[]).flatMap(
|
|
193
|
-
const codes = branches.map(
|
|
174
|
+
const branches = (schema.oneOf as JsonSchemaObject[]).flatMap(within);
|
|
175
|
+
const codes = branches.map(code);
|
|
194
176
|
|
|
195
177
|
if (
|
|
196
178
|
codes.every((code) => code !== undefined) &&
|
|
@@ -209,34 +191,78 @@ function keyOf(status: number, code: string): string {
|
|
|
209
191
|
|
|
210
192
|
/**
|
|
211
193
|
* How two definitions of one envelope differ in what a client reads: the
|
|
212
|
-
* fields one has and the other does not, or which of them are required
|
|
213
|
-
*
|
|
214
|
-
*
|
|
194
|
+
* fields one has and the other does not, or which of them are required —
|
|
195
|
+
* at any depth, by path, so a format that nests its fields
|
|
196
|
+
* (`error.retryAfter`) is compared as closely as a flat one. Wording — a
|
|
197
|
+
* `message` example, `number` against `integer` for the status — is not a
|
|
198
|
+
* difference a client would notice, and is not one.
|
|
215
199
|
*/
|
|
216
200
|
function differenceOf(
|
|
217
201
|
kept: JsonSchemaObject,
|
|
218
202
|
other: JsonSchemaObject,
|
|
219
203
|
): string | undefined {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const keptFields = fields(kept);
|
|
224
|
-
const otherFields = fields(other);
|
|
204
|
+
const keptShape = shapeOf(kept);
|
|
205
|
+
const otherShape = shapeOf(other);
|
|
225
206
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
207
|
+
const changed = [
|
|
208
|
+
...otherShape.fields.filter((path) => !keptShape.fields.includes(path)),
|
|
209
|
+
...keptShape.fields.filter((path) => !otherShape.fields.includes(path)),
|
|
210
|
+
];
|
|
229
211
|
|
|
230
212
|
if (changed.length > 0) {
|
|
231
213
|
return `fields: ${changed.join(", ")}`;
|
|
232
214
|
}
|
|
233
215
|
|
|
234
|
-
|
|
235
|
-
JSON.stringify(
|
|
236
|
-
[...(Array.isArray(schema.required) ? schema.required : [])].sort(),
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
return required(kept) === required(other)
|
|
216
|
+
return JSON.stringify(keptShape.required) ===
|
|
217
|
+
JSON.stringify(otherShape.required)
|
|
240
218
|
? undefined
|
|
241
219
|
: "which fields are required";
|
|
242
220
|
}
|
|
221
|
+
|
|
222
|
+
/** How deep {@link shapeOf} looks; an envelope is a few levels at most. */
|
|
223
|
+
const shapeDepth = 8;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The paths of an object schema's fields, and of the required ones among
|
|
227
|
+
* them, following `properties` down. Sorted, so two shapes compare as
|
|
228
|
+
* strings.
|
|
229
|
+
*/
|
|
230
|
+
function shapeOf(schema: JsonSchemaObject): {
|
|
231
|
+
fields: string[];
|
|
232
|
+
required: string[];
|
|
233
|
+
} {
|
|
234
|
+
const fields: string[] = [];
|
|
235
|
+
const required: string[] = [];
|
|
236
|
+
|
|
237
|
+
const walk = (node: JsonSchemaObject, prefix: string, depth: number) => {
|
|
238
|
+
const properties = node.properties;
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
depth > shapeDepth ||
|
|
242
|
+
properties === null ||
|
|
243
|
+
typeof properties !== "object"
|
|
244
|
+
) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const requiredHere = Array.isArray(node.required) ? node.required : [];
|
|
249
|
+
|
|
250
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
251
|
+
const path = `${prefix}${name}`;
|
|
252
|
+
|
|
253
|
+
fields.push(path);
|
|
254
|
+
|
|
255
|
+
if (requiredHere.includes(name)) {
|
|
256
|
+
required.push(path);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (child !== null && typeof child === "object") {
|
|
260
|
+
walk(child as JsonSchemaObject, `${path}.`, depth + 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
walk(schema, "", 0);
|
|
266
|
+
|
|
267
|
+
return { fields: fields.sort(), required: required.sort() };
|
|
268
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape the document gives an error.
|
|
3
|
+
*
|
|
4
|
+
* Every failure an application answers with reaches its `onError` hooks,
|
|
5
|
+
* so one hook sets the format of all of them. The document is generated
|
|
6
|
+
* from the routes, not from that hook, and cannot see what it does: an
|
|
7
|
+
* application with a format of its own says so here too, and the framework's
|
|
8
|
+
* failures, the hooks' refusals and the routes' own envelopes are described
|
|
9
|
+
* in it. Keeping the two in step is the application's to do — the core knows
|
|
10
|
+
* nothing about documents, and a function cannot be read for the shape it
|
|
11
|
+
* returns.
|
|
12
|
+
*
|
|
13
|
+
* Without it the format is the framework's envelope,
|
|
14
|
+
* `{ status, message, error }`.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { JsonSchemaObject } from "./emit.ts";
|
|
20
|
+
import type { JsonSchema, JsonSchemaKeywords } from "./json-schema.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One failure the document describes, as the generator knows it.
|
|
24
|
+
*
|
|
25
|
+
* This is everything known about a failure before it is answered: its
|
|
26
|
+
* status, its code when the framework or the hook knows it, an example of
|
|
27
|
+
* its message, and what else it carries — the `issues` of a validation
|
|
28
|
+
* failure, the `retryAfter` of a rate limit. Where each goes in the body
|
|
29
|
+
* is the format's to decide.
|
|
30
|
+
*/
|
|
31
|
+
export interface DocumentedFailure {
|
|
32
|
+
readonly status: number;
|
|
33
|
+
|
|
34
|
+
/** The code a client branches on, when it is known. */
|
|
35
|
+
readonly error?: string;
|
|
36
|
+
|
|
37
|
+
/** An example of the message; wording, never a contract. */
|
|
38
|
+
readonly message?: string;
|
|
39
|
+
|
|
40
|
+
/** What the failure carries besides, as JSON Schema by name. */
|
|
41
|
+
readonly fields: Readonly<Record<string, JsonSchema>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How the application's errors look, for the document to describe them. */
|
|
45
|
+
export interface ErrorFormat {
|
|
46
|
+
/** The body of one failure. */
|
|
47
|
+
readonly schema: (failure: DocumentedFailure) => JsonSchemaKeywords;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The top-level field that holds the code, for a client to discriminate
|
|
51
|
+
* on — `"code"` in `{ code, message }`. A status whose alternatives are
|
|
52
|
+
* all envelopes then carries a `discriminator` on it.
|
|
53
|
+
*
|
|
54
|
+
* None unless named: OpenAPI discriminates on a top-level field only, and
|
|
55
|
+
* a format that nests its code — `{ error: { code } }` — has none to
|
|
56
|
+
* name.
|
|
57
|
+
*/
|
|
58
|
+
readonly discriminator?: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The code of a schema a route or a hook declared, when it is an
|
|
62
|
+
* envelope in this format — how the document knows a route's own `404`
|
|
63
|
+
* is the same failure as a hook's, and gives both one definition.
|
|
64
|
+
*
|
|
65
|
+
* Read off the JSON Schema the validator emitted. By default, the
|
|
66
|
+
* `const` of the discriminator's field, when there is one; a nested
|
|
67
|
+
* format says where its code is.
|
|
68
|
+
*/
|
|
69
|
+
readonly code?: (schema: JsonSchemaKeywords) => string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the generator works with: every part of a format, filled in. */
|
|
73
|
+
export interface ResolvedFormat {
|
|
74
|
+
describe(failure: DocumentedFailure): JsonSchemaObject;
|
|
75
|
+
code(schema: JsonSchemaObject): string | undefined;
|
|
76
|
+
readonly discriminator: string | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A format as the generator uses it. Absent, the framework's envelope,
|
|
81
|
+
* discriminated on `error`.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveFormat(format: ErrorFormat | undefined): ResolvedFormat {
|
|
84
|
+
if (format === undefined) {
|
|
85
|
+
return {
|
|
86
|
+
describe: envelope,
|
|
87
|
+
code: codeIn("error"),
|
|
88
|
+
discriminator: "error",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const discriminator = format.discriminator;
|
|
93
|
+
const read = format.code;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
describe: (failure) => format.schema(failure) as JsonSchemaObject,
|
|
97
|
+
code: read
|
|
98
|
+
? (schema) => read(schema as JsonSchemaKeywords)
|
|
99
|
+
: discriminator === undefined
|
|
100
|
+
? () => undefined
|
|
101
|
+
: codeIn(discriminator),
|
|
102
|
+
discriminator,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The body of one failure in the framework's envelope, as JSON Schema.
|
|
108
|
+
*
|
|
109
|
+
* The status is a `const`, and so is the code whenever it is known: a
|
|
110
|
+
* generated client can then discriminate on `error` instead of receiving
|
|
111
|
+
* "an object with three strings", and a reader of the document sees
|
|
112
|
+
* `"BODY_TOO_LARGE"` rather than `string`.
|
|
113
|
+
*
|
|
114
|
+
* The message is documented by example, never as a `const` — it is wording
|
|
115
|
+
* meant for a human, the one field a client must not match on, and the one
|
|
116
|
+
* this project reserves the right to reword.
|
|
117
|
+
*
|
|
118
|
+
* Fields a failure carries besides come after the three, and never in
|
|
119
|
+
* their place.
|
|
120
|
+
*/
|
|
121
|
+
function envelope(failure: DocumentedFailure): JsonSchemaObject {
|
|
122
|
+
const own = {
|
|
123
|
+
status: { type: "integer", const: failure.status },
|
|
124
|
+
message: failure.message
|
|
125
|
+
? { type: "string", examples: [failure.message] }
|
|
126
|
+
: { type: "string" },
|
|
127
|
+
error: failure.error
|
|
128
|
+
? { type: "string", const: failure.error }
|
|
129
|
+
: { type: "string" },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const added = Object.keys(failure.fields).filter(
|
|
133
|
+
(name) => !Object.hasOwn(own, name),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
type: "object",
|
|
138
|
+
required: ["status", "message", "error", ...added],
|
|
139
|
+
properties: { ...failure.fields, ...own },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reads the code of an envelope from one top-level field: required, and
|
|
145
|
+
* holding one string — a `const`, or an `enum` of one value.
|
|
146
|
+
*/
|
|
147
|
+
function codeIn(
|
|
148
|
+
field: string,
|
|
149
|
+
): (schema: JsonSchemaObject) => string | undefined {
|
|
150
|
+
return (schema) => {
|
|
151
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
152
|
+
|
|
153
|
+
if (!required.includes(field)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const value = (
|
|
158
|
+
schema.properties as
|
|
159
|
+
| Record<string, { const?: unknown; enum?: unknown } | undefined>
|
|
160
|
+
| undefined
|
|
161
|
+
)?.[field];
|
|
162
|
+
|
|
163
|
+
if (typeof value?.const === "string") {
|
|
164
|
+
return value.const;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (
|
|
168
|
+
Array.isArray(value?.enum) &&
|
|
169
|
+
value.enum.length === 1 &&
|
|
170
|
+
typeof value.enum[0] === "string"
|
|
171
|
+
) {
|
|
172
|
+
return value.enum[0];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return undefined;
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { contributionsOf } from "./annotations.ts";
|
|
|
24
24
|
import { schemaComponents } from "./components.ts";
|
|
25
25
|
import type { OpenApiDocument, PathItemObject } from "./document.ts";
|
|
26
26
|
import { envelopes as envelopeDefinitions } from "./envelopes.ts";
|
|
27
|
+
import type { ErrorFormat } from "./errors.ts";
|
|
28
|
+
import { resolveFormat } from "./errors.ts";
|
|
27
29
|
import { declareEnvelopes, operationIds, operationOf } from "./operation.ts";
|
|
28
30
|
import { toTemplate } from "./parameters.ts";
|
|
29
31
|
|
|
@@ -50,6 +52,7 @@ export type {
|
|
|
50
52
|
PathItemObject,
|
|
51
53
|
ResponseObject,
|
|
52
54
|
} from "./document.ts";
|
|
55
|
+
export type { DocumentedFailure, ErrorFormat } from "./errors.ts";
|
|
53
56
|
export type {
|
|
54
57
|
JsonSchema,
|
|
55
58
|
JsonSchemaKeywords,
|
|
@@ -67,6 +70,33 @@ export interface OpenApiOptions {
|
|
|
67
70
|
|
|
68
71
|
/** Servers the API is reachable at. */
|
|
69
72
|
readonly servers?: OpenApiDocument["servers"];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How the application's errors look, when not like the framework's
|
|
76
|
+
* envelope — for an application whose `onError` hook answers in a format
|
|
77
|
+
* of its own. The framework's failures, the hooks' refusals and the
|
|
78
|
+
* routes' own envelopes are then described in it.
|
|
79
|
+
*
|
|
80
|
+
* @example `{ code, message }`, discriminated on `code`
|
|
81
|
+
* ```ts
|
|
82
|
+
* openapi(app, {
|
|
83
|
+
* info,
|
|
84
|
+
* errors: {
|
|
85
|
+
* schema: ({ error, message, fields }) => ({
|
|
86
|
+
* type: "object",
|
|
87
|
+
* required: ["code", "message", ...Object.keys(fields)],
|
|
88
|
+
* properties: {
|
|
89
|
+
* code: error ? { type: "string", const: error } : { type: "string" },
|
|
90
|
+
* message: { type: "string", ...(message ? { examples: [message] } : {}) },
|
|
91
|
+
* ...fields,
|
|
92
|
+
* },
|
|
93
|
+
* }),
|
|
94
|
+
* discriminator: "code",
|
|
95
|
+
* },
|
|
96
|
+
* });
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
readonly errors?: ErrorFormat;
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
/**
|
|
@@ -111,7 +141,10 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
111
141
|
const warnings: GeneratorWarning[] = [];
|
|
112
142
|
const schemes: Record<string, unknown> = {};
|
|
113
143
|
const components = schemaComponents();
|
|
114
|
-
const envelopes = envelopeDefinitions(
|
|
144
|
+
const envelopes = envelopeDefinitions(
|
|
145
|
+
components,
|
|
146
|
+
resolveFormat(options.errors),
|
|
147
|
+
);
|
|
115
148
|
const ids = operationIds();
|
|
116
149
|
const described = app.entries.filter(
|
|
117
150
|
(entry) => !entry.ws && !entry.def.docs?.hidden,
|
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
|
|
@@ -492,7 +495,12 @@ function failures(
|
|
|
492
495
|
message?: string,
|
|
493
496
|
fields?: DocumentedResponse["fields"],
|
|
494
497
|
): Record<string, unknown> => {
|
|
495
|
-
const described =
|
|
498
|
+
const described = envelopes.format.describe({
|
|
499
|
+
status,
|
|
500
|
+
...(error === undefined ? {} : { error }),
|
|
501
|
+
...(message === undefined ? {} : { message }),
|
|
502
|
+
fields: fields ?? {},
|
|
503
|
+
});
|
|
496
504
|
|
|
497
505
|
const ref =
|
|
498
506
|
error === undefined
|
|
@@ -511,7 +519,12 @@ function failures(
|
|
|
511
519
|
envelopes.ref(
|
|
512
520
|
options.validationStatus,
|
|
513
521
|
"VALIDATION_FAILED",
|
|
514
|
-
|
|
522
|
+
envelopes.format.describe({
|
|
523
|
+
status: options.validationStatus,
|
|
524
|
+
error: "VALIDATION_FAILED",
|
|
525
|
+
message: "Validation failed",
|
|
526
|
+
fields: { issues },
|
|
527
|
+
}),
|
|
515
528
|
warn,
|
|
516
529
|
) as unknown as Record<string, unknown>,
|
|
517
530
|
],
|
|
@@ -670,7 +683,8 @@ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
|
|
|
670
683
|
* builds a tagged union and narrows on the code, instead of trying the
|
|
671
684
|
* shapes in turn. The mapping is spelled out, because without it OpenAPI
|
|
672
685
|
* matches the value against the component's name — `Unauthorized`, where
|
|
673
|
-
* the body says `UNAUTHORIZED`.
|
|
686
|
+
* the body says `UNAUTHORIZED`. The field is the error format's; a format
|
|
687
|
+
* that names none — its code nested below the top — has no discriminator.
|
|
674
688
|
*/
|
|
675
689
|
function unionOf(
|
|
676
690
|
schemas: readonly Record<string, unknown>[],
|
|
@@ -682,6 +696,12 @@ function unionOf(
|
|
|
682
696
|
return only;
|
|
683
697
|
}
|
|
684
698
|
|
|
699
|
+
const propertyName = envelopes.format.discriminator;
|
|
700
|
+
|
|
701
|
+
if (propertyName === undefined) {
|
|
702
|
+
return { anyOf: schemas };
|
|
703
|
+
}
|
|
704
|
+
|
|
685
705
|
const mapping: Record<string, string> = {};
|
|
686
706
|
|
|
687
707
|
for (const schema of schemas) {
|
|
@@ -697,7 +717,7 @@ function unionOf(
|
|
|
697
717
|
|
|
698
718
|
return {
|
|
699
719
|
anyOf: schemas,
|
|
700
|
-
discriminator: { propertyName
|
|
720
|
+
discriminator: { propertyName, mapping },
|
|
701
721
|
};
|
|
702
722
|
}
|
|
703
723
|
|
|
@@ -730,65 +750,21 @@ function describeStatus(status: string): string {
|
|
|
730
750
|
}
|
|
731
751
|
|
|
732
752
|
/**
|
|
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.
|
|
753
|
+
* What a validation failure carries besides the code and the message: every
|
|
754
|
+
* issue, with where it is. Handed to the error format as a field, for the
|
|
755
|
+
* format to place.
|
|
745
756
|
*/
|
|
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 {
|
|
757
|
+
const issues: JsonSchema = {
|
|
758
|
+
type: "array",
|
|
759
|
+
items: {
|
|
763
760
|
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"],
|
|
761
|
+
required: ["message", "path"],
|
|
776
762
|
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
|
-
},
|
|
763
|
+
message: { type: "string" },
|
|
764
|
+
path: { type: "array", items: { type: ["string", "number"] } },
|
|
789
765
|
},
|
|
790
|
-
}
|
|
791
|
-
}
|
|
766
|
+
},
|
|
767
|
+
};
|
|
792
768
|
|
|
793
769
|
/**
|
|
794
770
|
* How a body of this type fails to parse — or nothing, for a text or a
|