@tetsujs/openapi 0.1.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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/src/annotations.d.ts +141 -0
- package/dist/src/annotations.d.ts.map +1 -0
- package/dist/src/components.d.ts +45 -0
- package/dist/src/components.d.ts.map +1 -0
- package/dist/src/controller.d.ts +91 -0
- package/dist/src/controller.d.ts.map +1 -0
- package/dist/src/document.d.ts +80 -0
- package/dist/src/document.d.ts.map +1 -0
- package/dist/src/emit.d.ts +40 -0
- package/dist/src/emit.d.ts.map +1 -0
- package/dist/src/index.d.ts +75 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +616 -0
- package/dist/src/index.js.map +17 -0
- package/dist/src/operation.d.ts +39 -0
- package/dist/src/operation.d.ts.map +1 -0
- package/dist/src/page.d.ts +54 -0
- package/dist/src/page.d.ts.map +1 -0
- package/dist/src/parameters.d.ts +47 -0
- package/dist/src/parameters.d.ts.map +1 -0
- package/package.json +52 -0
- package/src/annotations.ts +225 -0
- package/src/components.ts +90 -0
- package/src/controller.ts +175 -0
- package/src/document.ts +90 -0
- package/src/emit.ts +98 -0
- package/src/index.ts +203 -0
- package/src/operation.ts +583 -0
- package/src/page.ts +139 -0
- package/src/parameters.ts +145 -0
package/src/operation.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One route table entry, described as an OpenAPI operation.
|
|
3
|
+
*
|
|
4
|
+
* Bodies and responses live here; parameters are in `parameters.ts`. The
|
|
5
|
+
* error responses a route can produce without the author writing anything
|
|
6
|
+
* — a rejected schema, an unparsable body, a body over the limit — are
|
|
7
|
+
* derived from what the route declares and from the options the
|
|
8
|
+
* application actually runs with.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AnySchema,
|
|
15
|
+
AppOptions,
|
|
16
|
+
BodyType,
|
|
17
|
+
RouteTableEntry,
|
|
18
|
+
} from "@tetsujs/core";
|
|
19
|
+
import type { HookContributions } from "./annotations.ts";
|
|
20
|
+
import { contributionsOf } from "./annotations.ts";
|
|
21
|
+
import type { SchemaComponents } from "./components.ts";
|
|
22
|
+
import { failureName } from "./components.ts";
|
|
23
|
+
import type {
|
|
24
|
+
ContentMap,
|
|
25
|
+
OperationObject,
|
|
26
|
+
ResponseObject,
|
|
27
|
+
} from "./document.ts";
|
|
28
|
+
import type { JsonSchemaObject } from "./emit.ts";
|
|
29
|
+
import { emitted } from "./emit.ts";
|
|
30
|
+
import { partParameters, pathParameters } from "./parameters.ts";
|
|
31
|
+
|
|
32
|
+
/** Describes one route. */
|
|
33
|
+
export function operationOf(
|
|
34
|
+
entry: RouteTableEntry,
|
|
35
|
+
options: AppOptions,
|
|
36
|
+
components: SchemaComponents,
|
|
37
|
+
ids: OperationIds,
|
|
38
|
+
warn: (message: string) => void,
|
|
39
|
+
): OperationObject {
|
|
40
|
+
const schema = entry.def.schema;
|
|
41
|
+
|
|
42
|
+
const parameters = [
|
|
43
|
+
...pathParameters(entry.path, schema?.params, warn),
|
|
44
|
+
...partParameters(schema?.query, "query", warn),
|
|
45
|
+
...partParameters(schema?.headers, "header", warn),
|
|
46
|
+
...partParameters(schema?.cookies, "cookie", warn),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const body = requestBody(entry, warn);
|
|
50
|
+
const contributed = contributionsOf(entry.hooks, warn);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
operationId: ids.take(entry),
|
|
54
|
+
...(entry.def.docs?.summary === undefined
|
|
55
|
+
? {}
|
|
56
|
+
: { summary: entry.def.docs.summary }),
|
|
57
|
+
...(entry.def.docs?.description === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { description: entry.def.docs.description }),
|
|
60
|
+
...(entry.def.docs?.tags === undefined
|
|
61
|
+
? {}
|
|
62
|
+
: { tags: entry.def.docs.tags }),
|
|
63
|
+
...(entry.def.docs?.deprecated === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { deprecated: entry.def.docs.deprecated }),
|
|
66
|
+
...(parameters.length > 0 ? { parameters } : {}),
|
|
67
|
+
...(body ? { requestBody: body } : {}),
|
|
68
|
+
...(contributed.security.length > 0
|
|
69
|
+
? {
|
|
70
|
+
security: contributed.security.map((requirement) => ({
|
|
71
|
+
[requirement.name]: requirement.scopes ?? [],
|
|
72
|
+
})),
|
|
73
|
+
}
|
|
74
|
+
: {}),
|
|
75
|
+
responses: responses(entry, options, contributed, components, warn),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Hands out the `operationId` of every route in one document.
|
|
81
|
+
*
|
|
82
|
+
* Uniqueness is a requirement of the specification and cannot be decided
|
|
83
|
+
* one route at a time: the pair a name is built from — the controller and
|
|
84
|
+
* the field — is not unique on its own. One instance mounted under two
|
|
85
|
+
* prefixes is a documented pattern (`/v1` and `/v2` from the same
|
|
86
|
+
* controller), and two controller classes in different modules may simply
|
|
87
|
+
* share a class name. Either way the pair repeats while the routes differ.
|
|
88
|
+
*
|
|
89
|
+
* A repeat falls back to what the route is named by when it has no field
|
|
90
|
+
* at all — its method and path — because that is derived from the route
|
|
91
|
+
* rather than from the order routes were visited in. A numbered name is
|
|
92
|
+
* the last resort only, since it would move between documents as routes
|
|
93
|
+
* are added, and an `operationId` is what a generated client calls its
|
|
94
|
+
* method.
|
|
95
|
+
*/
|
|
96
|
+
export interface OperationIds {
|
|
97
|
+
/** The id for this route, unique within the document being built. */
|
|
98
|
+
take(entry: RouteTableEntry): string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function operationIds(): OperationIds {
|
|
102
|
+
const taken = new Set<string>();
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
take(entry: RouteTableEntry): string {
|
|
106
|
+
const preferred = declaredId(entry) ?? derivedId(entry);
|
|
107
|
+
|
|
108
|
+
if (!taken.has(preferred)) {
|
|
109
|
+
taken.add(preferred);
|
|
110
|
+
|
|
111
|
+
return preferred;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const derived = derivedId(entry);
|
|
115
|
+
|
|
116
|
+
if (!taken.has(derived)) {
|
|
117
|
+
taken.add(derived);
|
|
118
|
+
|
|
119
|
+
return derived;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let name = derived;
|
|
123
|
+
|
|
124
|
+
for (let attempt = 2; taken.has(name); attempt += 1) {
|
|
125
|
+
name = `${derived}${attempt}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
taken.add(name);
|
|
129
|
+
|
|
130
|
+
return name;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The name the controller gave it: the field, qualified by the controller
|
|
137
|
+
* — `usersSetAvatar` for `setAvatar` on `UsersController`.
|
|
138
|
+
*/
|
|
139
|
+
function declaredId(entry: RouteTableEntry): string | undefined {
|
|
140
|
+
if (entry.name === undefined) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const controller = entry.controller.replace(/Controller$/, "");
|
|
145
|
+
|
|
146
|
+
return `${lowerFirst(controller)}${capitalize(entry.name)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What identifies a route when its field does not: its method and path.
|
|
151
|
+
*
|
|
152
|
+
* Also the name of a route mounted standalone, which has no field to be
|
|
153
|
+
* called after.
|
|
154
|
+
*/
|
|
155
|
+
function derivedId(entry: RouteTableEntry): string {
|
|
156
|
+
return `${entry.method.toLowerCase()}${entry.path
|
|
157
|
+
.split(/[/:*]/)
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.map(capitalize)
|
|
160
|
+
.join("")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Describes the request body, when the route reads one.
|
|
165
|
+
*
|
|
166
|
+
* The media type follows the declaration, not a guess: `"form"` accepts
|
|
167
|
+
* both multipart and urlencoded at runtime, so both are listed — unless
|
|
168
|
+
* the schema declares a binary field, which only multipart can carry.
|
|
169
|
+
*/
|
|
170
|
+
function requestBody(
|
|
171
|
+
entry: RouteTableEntry,
|
|
172
|
+
warn: (message: string) => void,
|
|
173
|
+
): { required: boolean; content: ContentMap } | undefined {
|
|
174
|
+
const declared = entry.def.schema?.body;
|
|
175
|
+
const bodyType = entry.def.bodyType ?? (declared ? "json" : undefined);
|
|
176
|
+
|
|
177
|
+
if (!bodyType) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const described = declared
|
|
182
|
+
? emitted(declared, "the body", warn, "input")
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
const schema = described ? { schema: described } : {};
|
|
186
|
+
|
|
187
|
+
// Whether a body must be sent is a question about the wire, not about
|
|
188
|
+
// the schema: `json` and `form` parse it before anything is validated,
|
|
189
|
+
// and an empty one fails that parse with a `400` — so the client has to
|
|
190
|
+
// send something whether or not a shape was declared. `text` has no such
|
|
191
|
+
// step, because an absent body reads as `""` just like an empty one; if
|
|
192
|
+
// that string is unacceptable, the schema is where it says so. Neither
|
|
193
|
+
// has `stream`: an absent body is handed over as an empty stream.
|
|
194
|
+
const required = bodyType !== "text" && bodyType !== "stream";
|
|
195
|
+
|
|
196
|
+
if (bodyType === "stream") {
|
|
197
|
+
/**
|
|
198
|
+
* Bytes with no shape to describe: the route takes the body unread, so
|
|
199
|
+
* the document says what it is rather than what is in it. A `stream`
|
|
200
|
+
* route cannot carry a body schema at all — the compiler refuses the
|
|
201
|
+
* pair — so there is never a `schema` to attach here.
|
|
202
|
+
*/
|
|
203
|
+
return { required, content: { "application/octet-stream": {} } };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (bodyType === "text") {
|
|
207
|
+
return { required, content: { "text/plain": schema } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (bodyType === "json") {
|
|
211
|
+
return { required, content: { "application/json": schema } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const content: ContentMap = { "multipart/form-data": schema };
|
|
215
|
+
|
|
216
|
+
if (!described || !carriesBinary(described)) {
|
|
217
|
+
content["application/x-www-form-urlencoded"] = schema;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { required, content };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether any part of an emitted schema describes binary content.
|
|
225
|
+
*
|
|
226
|
+
* Walked with a stack rather than by recursion, and remembering what it has
|
|
227
|
+
* already seen: the shape comes from an adapter, so its depth is the
|
|
228
|
+
* author's to choose and nothing says it is a tree. Recursion would answer
|
|
229
|
+
* a deep one by overflowing the stack, and a self-referential one by never
|
|
230
|
+
* returning — both while `createApp` is still assembling the application.
|
|
231
|
+
*/
|
|
232
|
+
function carriesBinary(root: unknown): boolean {
|
|
233
|
+
const pending: unknown[] = [root];
|
|
234
|
+
const seen = new Set<unknown>();
|
|
235
|
+
|
|
236
|
+
while (pending.length > 0) {
|
|
237
|
+
const node = pending.pop();
|
|
238
|
+
|
|
239
|
+
if (node === null || typeof node !== "object" || seen.has(node)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
seen.add(node);
|
|
244
|
+
|
|
245
|
+
if ((node as { contentEncoding?: unknown }).contentEncoding === "binary") {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const value of Object.values(node)) {
|
|
250
|
+
pending.push(value);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Describes what the route answers with.
|
|
259
|
+
*
|
|
260
|
+
* The declared responses come first; the framework's own failures fill in
|
|
261
|
+
* around them, and never over them — an author who documented `422`
|
|
262
|
+
* himself has said something more specific than this could.
|
|
263
|
+
*/
|
|
264
|
+
function responses(
|
|
265
|
+
entry: RouteTableEntry,
|
|
266
|
+
options: AppOptions,
|
|
267
|
+
contributed: HookContributions,
|
|
268
|
+
components: SchemaComponents,
|
|
269
|
+
warn: (message: string) => void,
|
|
270
|
+
): Record<string, ResponseObject> {
|
|
271
|
+
const declared: Record<string, ResponseObject> = {};
|
|
272
|
+
const response = entry.def.schema?.response;
|
|
273
|
+
|
|
274
|
+
if (response && "~standard" in response) {
|
|
275
|
+
declared["200"] = jsonResponse(
|
|
276
|
+
response as AnySchema,
|
|
277
|
+
"Successful response",
|
|
278
|
+
warn,
|
|
279
|
+
);
|
|
280
|
+
} else if (response) {
|
|
281
|
+
for (const [status, schema] of Object.entries(response)) {
|
|
282
|
+
declared[status] =
|
|
283
|
+
schema === null
|
|
284
|
+
? { description: describeStatus(status) }
|
|
285
|
+
: jsonResponse(schema, describeStatus(status), warn);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!declared["200"] && !hasSuccess(declared)) {
|
|
290
|
+
declared["200"] = { description: "Successful response" };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
...declared,
|
|
295
|
+
...frameworkFailures(
|
|
296
|
+
entry,
|
|
297
|
+
options,
|
|
298
|
+
contributed,
|
|
299
|
+
declared,
|
|
300
|
+
components,
|
|
301
|
+
warn,
|
|
302
|
+
),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function frameworkFailures(
|
|
307
|
+
entry: RouteTableEntry,
|
|
308
|
+
options: AppOptions,
|
|
309
|
+
contributed: HookContributions,
|
|
310
|
+
declared: Record<string, ResponseObject>,
|
|
311
|
+
components: SchemaComponents,
|
|
312
|
+
warn: (message: string) => void,
|
|
313
|
+
): Record<string, ResponseObject> {
|
|
314
|
+
const schema = entry.def.schema;
|
|
315
|
+
const failures = new Map<string, Failure[]>();
|
|
316
|
+
|
|
317
|
+
const add = (status: number, failure: Failure): void => {
|
|
318
|
+
const key = String(status);
|
|
319
|
+
|
|
320
|
+
failures.set(key, [...(failures.get(key) ?? []), failure]);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** Defines the envelope of one failure once, and refers to it. */
|
|
324
|
+
const envelopeRef = (
|
|
325
|
+
status: number,
|
|
326
|
+
error?: string,
|
|
327
|
+
message?: string,
|
|
328
|
+
): Record<string, unknown> =>
|
|
329
|
+
components.ref(
|
|
330
|
+
failureName(status, error),
|
|
331
|
+
envelope(status, error, message),
|
|
332
|
+
) as unknown as Record<string, unknown>;
|
|
333
|
+
|
|
334
|
+
if (schema?.params || schema?.query || schema?.headers || schema?.body) {
|
|
335
|
+
add(options.validationStatus, {
|
|
336
|
+
description: "Request failed schema validation",
|
|
337
|
+
schema: components.ref(
|
|
338
|
+
failureName(options.validationStatus, "VALIDATION_FAILED"),
|
|
339
|
+
validationFailed(options.validationStatus),
|
|
340
|
+
) as unknown as Record<string, unknown>,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const requirement of contributed.security) {
|
|
345
|
+
const status = requirement.status ?? 401;
|
|
346
|
+
|
|
347
|
+
add(status, {
|
|
348
|
+
description:
|
|
349
|
+
requirement.description ??
|
|
350
|
+
`Request did not satisfy ${requirement.name}`,
|
|
351
|
+
schema: envelopeRef(status, requirement.error, requirement.message),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const response of contributed.responses) {
|
|
356
|
+
const described = response.schema
|
|
357
|
+
? emitted(response.schema, "a hook's response", warn)
|
|
358
|
+
: undefined;
|
|
359
|
+
|
|
360
|
+
add(response.status, {
|
|
361
|
+
description: response.description,
|
|
362
|
+
schema:
|
|
363
|
+
described ??
|
|
364
|
+
envelopeRef(response.status, response.error, response.message),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (schema?.body || entry.def.bodyType) {
|
|
369
|
+
const unparsable = parseFailure(entry.def.bodyType);
|
|
370
|
+
|
|
371
|
+
if (unparsable) {
|
|
372
|
+
add(400, {
|
|
373
|
+
description: "Body could not be parsed in the declared shape",
|
|
374
|
+
schema: envelopeRef(400, unparsable.error, unparsable.message),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
add(413, {
|
|
379
|
+
description: "Body exceeded the configured size limit",
|
|
380
|
+
schema: envelopeRef(
|
|
381
|
+
413,
|
|
382
|
+
"BODY_TOO_LARGE",
|
|
383
|
+
"Body exceeds the configured limit",
|
|
384
|
+
),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
add(500, {
|
|
389
|
+
description: "The request failed and nothing mapped the failure",
|
|
390
|
+
schema: envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const responses: Record<string, ResponseObject> = {};
|
|
394
|
+
|
|
395
|
+
for (const [status, list] of failures) {
|
|
396
|
+
responses[status] = merge(declared[status], list);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return responses;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** One way the framework itself can answer. */
|
|
403
|
+
interface Failure {
|
|
404
|
+
readonly description: string;
|
|
405
|
+
readonly schema: Record<string, unknown>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Folds everything that answers with one status into one response.
|
|
410
|
+
*
|
|
411
|
+
* Three things can meet on a status: what the route declared, and any
|
|
412
|
+
* number of failures the framework fills in — a rejected schema and an
|
|
413
|
+
* unparsable body collide on `400` as soon as an application configures
|
|
414
|
+
* `validation: { status: 400 }`, and a route that answers `404` itself
|
|
415
|
+
* shares that status with nothing while sharing `400` with the parser.
|
|
416
|
+
*
|
|
417
|
+
* All of them are true at once, so the document says so with an `anyOf`
|
|
418
|
+
* rather than picking a winner. The route's own body comes first: it is
|
|
419
|
+
* the answer the endpoint is about, and the failures are what can happen
|
|
420
|
+
* to it. Identical bodies are folded together — a `$ref` repeated is one
|
|
421
|
+
* alternative, not two.
|
|
422
|
+
*/
|
|
423
|
+
function merge(
|
|
424
|
+
declared: ResponseObject | undefined,
|
|
425
|
+
list: readonly Failure[],
|
|
426
|
+
): ResponseObject {
|
|
427
|
+
const descriptions = [
|
|
428
|
+
...(declared ? [declared.description] : []),
|
|
429
|
+
...list.map((failure) => failure.description),
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
const schemas = distinct([
|
|
433
|
+
...declaredSchemas(declared),
|
|
434
|
+
...list.map((failure) => failure.schema),
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
const [only] = schemas;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
description: descriptions.join("; "),
|
|
441
|
+
...(schemas.length === 1 && only
|
|
442
|
+
? { content: { "application/json": { schema: only } } }
|
|
443
|
+
: { content: { "application/json": { schema: { anyOf: schemas } } } }),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The body a declared response describes, if it describes one: a response
|
|
449
|
+
* whose schema could not be emitted contributes its description alone.
|
|
450
|
+
*/
|
|
451
|
+
function declaredSchemas(
|
|
452
|
+
declared: ResponseObject | undefined,
|
|
453
|
+
): Record<string, unknown>[] {
|
|
454
|
+
const schema = declared?.content?.["application/json"]?.schema;
|
|
455
|
+
|
|
456
|
+
return schema ? [schema] : [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function distinct(
|
|
460
|
+
schemas: readonly Record<string, unknown>[],
|
|
461
|
+
): Record<string, unknown>[] {
|
|
462
|
+
const seen = new Set<string>();
|
|
463
|
+
|
|
464
|
+
return schemas.filter((schema) => {
|
|
465
|
+
const fingerprint = JSON.stringify(schema);
|
|
466
|
+
|
|
467
|
+
if (seen.has(fingerprint)) {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
seen.add(fingerprint);
|
|
472
|
+
|
|
473
|
+
return true;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function jsonResponse(
|
|
478
|
+
schema: unknown,
|
|
479
|
+
description: string,
|
|
480
|
+
warn: (message: string) => void,
|
|
481
|
+
): ResponseObject {
|
|
482
|
+
const described = emitted(schema, "a response", warn);
|
|
483
|
+
|
|
484
|
+
if (!described) {
|
|
485
|
+
return { description };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
description,
|
|
490
|
+
content: { "application/json": { schema: described } },
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function hasSuccess(declared: Record<string, ResponseObject>): boolean {
|
|
495
|
+
return Object.keys(declared).some((status) => status.startsWith("2"));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function describeStatus(status: string): string {
|
|
499
|
+
if (status === "204") {
|
|
500
|
+
return "No content";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return status.startsWith("2") ? "Successful response" : `Response ${status}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The body of one framework-produced failure, as JSON Schema.
|
|
508
|
+
*
|
|
509
|
+
* The status is a `const`, and so is the code whenever the framework knows
|
|
510
|
+
* which one it will be: a generated client can then discriminate on
|
|
511
|
+
* `error` instead of receiving "an object with three strings", and a
|
|
512
|
+
* reader of the document sees `"BODY_TOO_LARGE"` rather than `string`.
|
|
513
|
+
*
|
|
514
|
+
* The message is documented by example, never as a `const` — it is wording
|
|
515
|
+
* meant for a human, the one field a client must not match on, and the one
|
|
516
|
+
* this project reserves the right to reword.
|
|
517
|
+
*/
|
|
518
|
+
function envelope(
|
|
519
|
+
status: number,
|
|
520
|
+
error?: string,
|
|
521
|
+
message?: string,
|
|
522
|
+
): JsonSchemaObject {
|
|
523
|
+
return {
|
|
524
|
+
type: "object",
|
|
525
|
+
required: ["status", "message", "error"],
|
|
526
|
+
properties: {
|
|
527
|
+
status: { type: "integer", const: status },
|
|
528
|
+
message: message
|
|
529
|
+
? { type: "string", examples: [message] }
|
|
530
|
+
: { type: "string" },
|
|
531
|
+
error: error ? { type: "string", const: error } : { type: "string" },
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The validation envelope: the standard three fields, plus the issues. */
|
|
537
|
+
function validationFailed(status: number): Record<string, unknown> {
|
|
538
|
+
const base = envelope(status, "VALIDATION_FAILED", "Validation failed");
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
...base,
|
|
542
|
+
required: ["status", "message", "error", "issues"],
|
|
543
|
+
properties: {
|
|
544
|
+
...(base.properties as Record<string, unknown>),
|
|
545
|
+
issues: {
|
|
546
|
+
type: "array",
|
|
547
|
+
items: {
|
|
548
|
+
type: "object",
|
|
549
|
+
required: ["message", "path"],
|
|
550
|
+
properties: {
|
|
551
|
+
message: { type: "string" },
|
|
552
|
+
path: { type: "array", items: { type: ["string", "number"] } },
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* How a body of this type fails to parse — or nothing, for a text or a
|
|
562
|
+
* stream body: one is whatever bytes arrived, the other is handed over
|
|
563
|
+
* unread, and neither can be malformed.
|
|
564
|
+
*/
|
|
565
|
+
function parseFailure(
|
|
566
|
+
bodyType: BodyType | undefined,
|
|
567
|
+
): { error: string; message: string } | undefined {
|
|
568
|
+
if (bodyType === "text" || bodyType === "stream") {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return bodyType === "form"
|
|
573
|
+
? { error: "MALFORMED_FORM", message: "Body is not a valid form" }
|
|
574
|
+
: { error: "MALFORMED_JSON", message: "Body is not valid JSON" };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function capitalize(value: string): string {
|
|
578
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function lowerFirst(value: string): string {
|
|
582
|
+
return value.charAt(0).toLowerCase() + value.slice(1);
|
|
583
|
+
}
|