@tetsujs/openapi 0.4.1 → 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 +140 -3
- package/dist/src/annotations.d.ts +40 -0
- package/dist/src/annotations.d.ts.map +1 -1
- package/dist/src/controller.d.ts +32 -9
- package/dist/src/controller.d.ts.map +1 -1
- package/dist/src/document.d.ts +7 -0
- package/dist/src/document.d.ts.map +1 -1
- package/dist/src/emit.d.ts +3 -1
- package/dist/src/emit.d.ts.map +1 -1
- package/dist/src/envelopes.d.ts +72 -0
- package/dist/src/envelopes.d.ts.map +1 -0
- package/dist/src/errors.d.ts +74 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/index.d.ts +31 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +374 -135
- package/dist/src/index.js.map +11 -9
- package/dist/src/json-schema.d.ts +91 -0
- package/dist/src/json-schema.d.ts.map +1 -0
- package/dist/src/operation.d.ts +10 -1
- package/dist/src/operation.d.ts.map +1 -1
- package/dist/src/page.d.ts +20 -0
- package/dist/src/page.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/annotations.ts +43 -0
- package/src/controller.ts +69 -22
- package/src/document.ts +9 -0
- package/src/emit.ts +3 -1
- package/src/envelopes.ts +268 -0
- package/src/errors.ts +177 -0
- package/src/index.ts +52 -6
- package/src/json-schema.ts +108 -0
- package/src/operation.ts +328 -187
- package/src/page.ts +65 -6
package/src/envelopes.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error envelopes, one definition per status and code.
|
|
3
|
+
*
|
|
4
|
+
* An envelope reaches a document from four directions: a route's response
|
|
5
|
+
* map, a hook's annotation, a guard's security requirement, and the
|
|
6
|
+
* framework's own failures. Each used to be described where it arrived —
|
|
7
|
+
* a route's inline, the rest by reference — so one `UNAUTHORIZED` could be
|
|
8
|
+
* listed twice under one status, once as a `$ref` and once as the same
|
|
9
|
+
* shape without a name, and a generated client got a named type next to an
|
|
10
|
+
* anonymous twin.
|
|
11
|
+
*
|
|
12
|
+
* Here every envelope, whoever declared it, becomes a reference to the
|
|
13
|
+
* one definition of its status and code. That is also what makes a status
|
|
14
|
+
* discriminable: a client can branch on `error` only if every code under
|
|
15
|
+
* the status has exactly one shape.
|
|
16
|
+
*
|
|
17
|
+
* The route's definition wins. It is the author's own contract — usually
|
|
18
|
+
* the stricter one, a validator's `additionalProperties: false` — where a
|
|
19
|
+
* hook's is the framework's generic envelope. The routes are therefore
|
|
20
|
+
* read first, before any operation is built, so the winner does not depend
|
|
21
|
+
* on which route happens to come first in the table.
|
|
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
|
+
*
|
|
27
|
+
* Internal to the package.
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { SchemaComponents, SchemaRef } from "./components.ts";
|
|
33
|
+
import { failureName } from "./components.ts";
|
|
34
|
+
import type { JsonSchemaObject } from "./emit.ts";
|
|
35
|
+
import type { ResolvedFormat } from "./errors.ts";
|
|
36
|
+
|
|
37
|
+
/** The definitions of every envelope of one document. */
|
|
38
|
+
export interface Envelopes {
|
|
39
|
+
/** The document's error format. */
|
|
40
|
+
readonly format: ResolvedFormat;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Records a route's definition, before anything is referenced. The first
|
|
44
|
+
* route to declare a status and code defines it.
|
|
45
|
+
*/
|
|
46
|
+
declare(status: number, code: string, schema: JsonSchemaObject): void;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The reference to the definition of a status and code.
|
|
50
|
+
*
|
|
51
|
+
* `schema` is how the caller describes it. When a definition already
|
|
52
|
+
* exists, that is what is referenced; if the caller's differs from it
|
|
53
|
+
* in fields, `warn` hears about it — once per document, not once per
|
|
54
|
+
* operation the hook runs on.
|
|
55
|
+
*/
|
|
56
|
+
ref(
|
|
57
|
+
status: number,
|
|
58
|
+
code: string,
|
|
59
|
+
schema: JsonSchemaObject,
|
|
60
|
+
warn: (message: string) => void,
|
|
61
|
+
): SchemaRef;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The code a reference stands for, when it is one this handed out — what
|
|
65
|
+
* a discriminator maps back from.
|
|
66
|
+
*/
|
|
67
|
+
codeOf(ref: string): string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function envelopes(
|
|
71
|
+
components: SchemaComponents,
|
|
72
|
+
format: ResolvedFormat,
|
|
73
|
+
): Envelopes {
|
|
74
|
+
const declared = new Map<string, JsonSchemaObject>();
|
|
75
|
+
const defined = new Map<string, JsonSchemaObject>();
|
|
76
|
+
const refs = new Map<string, SchemaRef>();
|
|
77
|
+
const codes = new Map<string, string>();
|
|
78
|
+
const names = new Map<string, string>();
|
|
79
|
+
const reported = new Set<string>();
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
format,
|
|
83
|
+
|
|
84
|
+
declare(status, code, schema) {
|
|
85
|
+
const key = keyOf(status, code);
|
|
86
|
+
|
|
87
|
+
if (!declared.has(key)) {
|
|
88
|
+
declared.set(key, schema);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
ref(status, code, schema, warn) {
|
|
93
|
+
const key = keyOf(status, code);
|
|
94
|
+
const definition = declared.get(key) ?? defined.get(key) ?? schema;
|
|
95
|
+
|
|
96
|
+
defined.set(key, definition);
|
|
97
|
+
|
|
98
|
+
const difference = differenceOf(definition, schema);
|
|
99
|
+
|
|
100
|
+
if (difference && !reported.has(`${key} ${difference}`)) {
|
|
101
|
+
reported.add(`${key} ${difference}`);
|
|
102
|
+
warn(
|
|
103
|
+
`the ${status} "${code}" is described by one definition in the document, and a definition that differs from it in ${difference} is not described`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const existing = refs.get(key);
|
|
108
|
+
|
|
109
|
+
if (existing) {
|
|
110
|
+
return existing;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const created = components.ref(nameOf(status, code), definition);
|
|
114
|
+
|
|
115
|
+
refs.set(key, created);
|
|
116
|
+
codes.set(created.$ref, code);
|
|
117
|
+
|
|
118
|
+
return created;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
codeOf(ref) {
|
|
122
|
+
return codes.get(ref);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The name a status and code are defined under: the code's, unless the
|
|
128
|
+
* same code under another status has it already, in which case the
|
|
129
|
+
* status tells the two apart — `Invalid` and `Invalid409`.
|
|
130
|
+
*/
|
|
131
|
+
function nameOf(status: number, code: string): string {
|
|
132
|
+
const name = failureName(status, code);
|
|
133
|
+
const holder = names.get(name);
|
|
134
|
+
|
|
135
|
+
if (holder === undefined || holder === keyOf(status, code)) {
|
|
136
|
+
names.set(name, keyOf(status, code));
|
|
137
|
+
|
|
138
|
+
return name;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `${name}${status}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The alternatives a schema stands for, as a list the status's own union
|
|
147
|
+
* can take in.
|
|
148
|
+
*
|
|
149
|
+
* `anyOf` nested in `anyOf` says nothing its branches do not say side by
|
|
150
|
+
* side, so a schema that is only an `anyOf` is replaced by its branches.
|
|
151
|
+
* A `oneOf` is different — "exactly one" of two overlapping shapes refuses
|
|
152
|
+
* what both accept — and is taken apart only when its branches cannot
|
|
153
|
+
* overlap: envelopes with a different code each. A schema with anything
|
|
154
|
+
* beside its union, a `title` or a `$defs`, is left whole: those keywords
|
|
155
|
+
* belong to the union, and would be lost with it.
|
|
156
|
+
*/
|
|
157
|
+
export function branchesOf(
|
|
158
|
+
schema: JsonSchemaObject,
|
|
159
|
+
code: (schema: JsonSchemaObject) => string | undefined,
|
|
160
|
+
): JsonSchemaObject[] {
|
|
161
|
+
const keys = Object.keys(schema);
|
|
162
|
+
|
|
163
|
+
if (keys.length !== 1) {
|
|
164
|
+
return [schema];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const within = (branch: JsonSchemaObject) => branchesOf(branch, code);
|
|
168
|
+
|
|
169
|
+
if (Array.isArray(schema.anyOf)) {
|
|
170
|
+
return (schema.anyOf as JsonSchemaObject[]).flatMap(within);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (Array.isArray(schema.oneOf)) {
|
|
174
|
+
const branches = (schema.oneOf as JsonSchemaObject[]).flatMap(within);
|
|
175
|
+
const codes = branches.map(code);
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
codes.every((code) => code !== undefined) &&
|
|
179
|
+
new Set(codes).size === codes.length
|
|
180
|
+
) {
|
|
181
|
+
return branches;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return [schema];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function keyOf(status: number, code: string): string {
|
|
189
|
+
return `${status} ${code}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* How two definitions of one envelope differ in what a client reads: the
|
|
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.
|
|
199
|
+
*/
|
|
200
|
+
function differenceOf(
|
|
201
|
+
kept: JsonSchemaObject,
|
|
202
|
+
other: JsonSchemaObject,
|
|
203
|
+
): string | undefined {
|
|
204
|
+
const keptShape = shapeOf(kept);
|
|
205
|
+
const otherShape = shapeOf(other);
|
|
206
|
+
|
|
207
|
+
const changed = [
|
|
208
|
+
...otherShape.fields.filter((path) => !keptShape.fields.includes(path)),
|
|
209
|
+
...keptShape.fields.filter((path) => !otherShape.fields.includes(path)),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
if (changed.length > 0) {
|
|
213
|
+
return `fields: ${changed.join(", ")}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return JSON.stringify(keptShape.required) ===
|
|
217
|
+
JSON.stringify(otherShape.required)
|
|
218
|
+
? undefined
|
|
219
|
+
: "which fields are required";
|
|
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
|
@@ -23,10 +23,14 @@ import type { App } from "@tetsujs/core";
|
|
|
23
23
|
import { contributionsOf } from "./annotations.ts";
|
|
24
24
|
import { schemaComponents } from "./components.ts";
|
|
25
25
|
import type { OpenApiDocument, PathItemObject } from "./document.ts";
|
|
26
|
-
import {
|
|
26
|
+
import { envelopes as envelopeDefinitions } from "./envelopes.ts";
|
|
27
|
+
import type { ErrorFormat } from "./errors.ts";
|
|
28
|
+
import { resolveFormat } from "./errors.ts";
|
|
29
|
+
import { declareEnvelopes, operationIds, operationOf } from "./operation.ts";
|
|
27
30
|
import { toTemplate } from "./parameters.ts";
|
|
28
31
|
|
|
29
32
|
export type {
|
|
33
|
+
DocumentedHeader,
|
|
30
34
|
DocumentedResponse,
|
|
31
35
|
HookContributions,
|
|
32
36
|
HookDocs,
|
|
@@ -41,12 +45,19 @@ export type {
|
|
|
41
45
|
ContentMap,
|
|
42
46
|
DocumentInfo,
|
|
43
47
|
DocumentServer,
|
|
48
|
+
HeaderObject,
|
|
44
49
|
OpenApiDocument,
|
|
45
50
|
OperationObject,
|
|
46
51
|
ParameterObject,
|
|
47
52
|
PathItemObject,
|
|
48
53
|
ResponseObject,
|
|
49
54
|
} from "./document.ts";
|
|
55
|
+
export type { DocumentedFailure, ErrorFormat } from "./errors.ts";
|
|
56
|
+
export type {
|
|
57
|
+
JsonSchema,
|
|
58
|
+
JsonSchemaKeywords,
|
|
59
|
+
JsonSchemaType,
|
|
60
|
+
} from "./json-schema.ts";
|
|
50
61
|
export type { DocsAssets, DocsPageOptions, DocsUi } from "./page.ts";
|
|
51
62
|
export { docsPage } from "./page.ts";
|
|
52
63
|
|
|
@@ -59,6 +70,33 @@ export interface OpenApiOptions {
|
|
|
59
70
|
|
|
60
71
|
/** Servers the API is reachable at. */
|
|
61
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;
|
|
62
100
|
}
|
|
63
101
|
|
|
64
102
|
/**
|
|
@@ -103,7 +141,18 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
103
141
|
const warnings: GeneratorWarning[] = [];
|
|
104
142
|
const schemes: Record<string, unknown> = {};
|
|
105
143
|
const components = schemaComponents();
|
|
144
|
+
const envelopes = envelopeDefinitions(
|
|
145
|
+
components,
|
|
146
|
+
resolveFormat(options.errors),
|
|
147
|
+
);
|
|
106
148
|
const ids = operationIds();
|
|
149
|
+
const described = app.entries.filter(
|
|
150
|
+
(entry) => !entry.ws && !entry.def.docs?.hidden,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const entry of described) {
|
|
154
|
+
declareEnvelopes(entry, envelopes);
|
|
155
|
+
}
|
|
107
156
|
|
|
108
157
|
// Which route already occupies each slot of the document. Two routes the
|
|
109
158
|
// router keeps apart can still meet here, because a template says less
|
|
@@ -114,11 +163,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
114
163
|
// that works. It still loses it; it no longer does so without saying.
|
|
115
164
|
const occupied = new Map<string, string>();
|
|
116
165
|
|
|
117
|
-
for (const entry of
|
|
118
|
-
if (entry.ws || entry.def.docs?.hidden) {
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
166
|
+
for (const entry of described) {
|
|
122
167
|
const template = toTemplate(entry.path);
|
|
123
168
|
const route = `${entry.method} ${entry.path}`;
|
|
124
169
|
|
|
@@ -126,6 +171,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
126
171
|
entry,
|
|
127
172
|
app.options,
|
|
128
173
|
components,
|
|
174
|
+
envelopes,
|
|
129
175
|
ids,
|
|
130
176
|
(message) => {
|
|
131
177
|
warnings.push({ route, message });
|
|
@@ -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
|
+
}
|