@tetsujs/openapi 0.4.0 → 0.4.2
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 +82 -3
- package/dist/src/annotations.d.ts +93 -10
- package/dist/src/annotations.d.ts.map +1 -1
- package/dist/src/controller.d.ts +26 -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 +74 -0
- package/dist/src/envelopes.d.ts.map +1 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +360 -122
- package/dist/src/index.js.map +10 -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 +162 -21
- package/src/controller.ts +61 -22
- package/src/document.ts +9 -0
- package/src/emit.ts +3 -1
- package/src/envelopes.ts +242 -0
- package/src/index.ts +20 -6
- package/src/json-schema.ts +108 -0
- package/src/operation.ts +381 -152
- package/src/page.ts +65 -6
package/src/operation.ts
CHANGED
|
@@ -16,17 +16,25 @@ import type {
|
|
|
16
16
|
BodyType,
|
|
17
17
|
RouteTableEntry,
|
|
18
18
|
} from "@tetsujs/core";
|
|
19
|
-
import
|
|
19
|
+
import { errorBody } from "@tetsujs/core";
|
|
20
|
+
import type {
|
|
21
|
+
DocumentedResponse,
|
|
22
|
+
HookContributions,
|
|
23
|
+
SecurityRequirement,
|
|
24
|
+
} from "./annotations.ts";
|
|
20
25
|
import { contributionsOf } from "./annotations.ts";
|
|
21
26
|
import type { SchemaComponents } from "./components.ts";
|
|
22
27
|
import { failureName } from "./components.ts";
|
|
23
28
|
import type {
|
|
24
29
|
ContentMap,
|
|
30
|
+
HeaderObject,
|
|
25
31
|
OperationObject,
|
|
26
32
|
ResponseObject,
|
|
27
33
|
} from "./document.ts";
|
|
28
34
|
import type { JsonSchemaObject } from "./emit.ts";
|
|
29
35
|
import { emitted } from "./emit.ts";
|
|
36
|
+
import type { Envelopes } from "./envelopes.ts";
|
|
37
|
+
import { branchesOf, envelopeCode } from "./envelopes.ts";
|
|
30
38
|
import { partParameters, pathParameters } from "./parameters.ts";
|
|
31
39
|
|
|
32
40
|
/** Describes one route. */
|
|
@@ -34,6 +42,7 @@ export function operationOf(
|
|
|
34
42
|
entry: RouteTableEntry,
|
|
35
43
|
options: AppOptions,
|
|
36
44
|
components: SchemaComponents,
|
|
45
|
+
envelopes: Envelopes,
|
|
37
46
|
ids: OperationIds,
|
|
38
47
|
warn: (message: string) => void,
|
|
39
48
|
): OperationObject {
|
|
@@ -66,16 +75,153 @@ export function operationOf(
|
|
|
66
75
|
...(parameters.length > 0 ? { parameters } : {}),
|
|
67
76
|
...(body ? { requestBody: body } : {}),
|
|
68
77
|
...(contributed.security.length > 0
|
|
69
|
-
? {
|
|
70
|
-
security: contributed.security.map((requirement) => ({
|
|
71
|
-
[requirement.name]: requirement.scopes ?? [],
|
|
72
|
-
})),
|
|
73
|
-
}
|
|
78
|
+
? { security: securityOf(contributed.conditions) }
|
|
74
79
|
: {}),
|
|
75
|
-
responses: responses(
|
|
80
|
+
responses: responses(
|
|
81
|
+
entry,
|
|
82
|
+
options,
|
|
83
|
+
contributed,
|
|
84
|
+
components,
|
|
85
|
+
envelopes,
|
|
86
|
+
warn,
|
|
87
|
+
),
|
|
76
88
|
};
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Records the envelopes a route declares, so that its definitions are the
|
|
93
|
+
* ones the document keeps — whichever operation refers to them first.
|
|
94
|
+
*
|
|
95
|
+
* Runs before any operation is built. A schema that cannot describe itself
|
|
96
|
+
* is skipped without a word: building the operation says so, once.
|
|
97
|
+
*/
|
|
98
|
+
export function declareEnvelopes(
|
|
99
|
+
entry: RouteTableEntry,
|
|
100
|
+
envelopes: Envelopes,
|
|
101
|
+
): void {
|
|
102
|
+
for (const [status, schema] of declaredResponses(entry)) {
|
|
103
|
+
const described =
|
|
104
|
+
schema === null ? undefined : emitted(schema, "", () => {});
|
|
105
|
+
|
|
106
|
+
for (const branch of described ? branchesOf(described) : []) {
|
|
107
|
+
const code = envelopeCode(branch);
|
|
108
|
+
|
|
109
|
+
if (code !== undefined) {
|
|
110
|
+
envelopes.declare(Number(status), code, branch);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The route's response map as pairs; a single schema is its `200`. */
|
|
117
|
+
function declaredResponses(
|
|
118
|
+
entry: RouteTableEntry,
|
|
119
|
+
): [status: string, schema: AnySchema | null][] {
|
|
120
|
+
const response = entry.def.schema?.response;
|
|
121
|
+
|
|
122
|
+
if (!response) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if ("~standard" in response) {
|
|
127
|
+
return [["200", response as AnySchema]];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Object.entries(response) as [string, AnySchema | null][];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A described body as the alternatives the document lists: every envelope
|
|
135
|
+
* in it a reference to the one definition of its status and code, and a
|
|
136
|
+
* union taken apart into its branches, so that it joins the other answers
|
|
137
|
+
* of its status rather than nesting inside them.
|
|
138
|
+
*/
|
|
139
|
+
function branchesFor(
|
|
140
|
+
schema: JsonSchemaObject,
|
|
141
|
+
status: number,
|
|
142
|
+
envelopes: Envelopes,
|
|
143
|
+
warn: (message: string) => void,
|
|
144
|
+
): Record<string, unknown>[] {
|
|
145
|
+
return branchesOf(schema).map((branch) => {
|
|
146
|
+
const code = envelopeCode(branch);
|
|
147
|
+
|
|
148
|
+
return code === undefined
|
|
149
|
+
? branch
|
|
150
|
+
: (envelopes.ref(status, code, branch, warn) as unknown as Record<
|
|
151
|
+
string,
|
|
152
|
+
unknown
|
|
153
|
+
>);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The security a route asks for, as OpenAPI spells it.
|
|
159
|
+
*
|
|
160
|
+
* OpenAPI reads the entries of an operation's `security` as alternatives —
|
|
161
|
+
* any one of them authorizes a request — and the schemes inside one entry
|
|
162
|
+
* as all required. A route's conditions are the other way round: every
|
|
163
|
+
* hook runs, so every condition is required, and a condition is satisfied
|
|
164
|
+
* by any one of its alternatives. The entries are therefore the
|
|
165
|
+
* combinations — one alternative from each condition, taken together:
|
|
166
|
+
* a hook accepting a cookie *or* a token, next to a CSRF check, is
|
|
167
|
+
* `[{ cookie, csrf }, { token, csrf }]`. With no alternatives anywhere
|
|
168
|
+
* that is a single entry holding every scheme, which is the usual case.
|
|
169
|
+
*
|
|
170
|
+
* Two alternatives of one scheme in a combination ask for the scopes of
|
|
171
|
+
* both; a combination that comes out twice is listed once.
|
|
172
|
+
*/
|
|
173
|
+
function securityOf(
|
|
174
|
+
conditions: readonly (readonly SecurityRequirement[])[],
|
|
175
|
+
): Record<string, readonly string[]>[] {
|
|
176
|
+
let combinations: Record<string, readonly string[]>[] = [{}];
|
|
177
|
+
|
|
178
|
+
for (const alternatives of conditions) {
|
|
179
|
+
combinations = combinations.flatMap((combination) =>
|
|
180
|
+
alternatives.map((requirement) => ({
|
|
181
|
+
...combination,
|
|
182
|
+
[requirement.name]: joined(
|
|
183
|
+
combination[requirement.name],
|
|
184
|
+
requirement.scopes,
|
|
185
|
+
),
|
|
186
|
+
})),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
|
|
192
|
+
return combinations.filter((combination) => {
|
|
193
|
+
const key = JSON.stringify(
|
|
194
|
+
Object.entries(combination)
|
|
195
|
+
.map(([name, scopes]) => [name, [...scopes].sort()])
|
|
196
|
+
.sort(),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (seen.has(key)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
seen.add(key);
|
|
204
|
+
|
|
205
|
+
return true;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The scopes of a scheme already in a combination, and another's. */
|
|
210
|
+
function joined(
|
|
211
|
+
present: readonly string[] | undefined,
|
|
212
|
+
added: readonly string[] | undefined,
|
|
213
|
+
): readonly string[] {
|
|
214
|
+
const scopes = [...(present ?? [])];
|
|
215
|
+
|
|
216
|
+
for (const scope of added ?? []) {
|
|
217
|
+
if (!scopes.includes(scope)) {
|
|
218
|
+
scopes.push(scope);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return scopes;
|
|
223
|
+
}
|
|
224
|
+
|
|
79
225
|
/**
|
|
80
226
|
* Hands out the `operationId` of every route in one document.
|
|
81
227
|
*
|
|
@@ -270,90 +416,121 @@ function responses(
|
|
|
270
416
|
options: AppOptions,
|
|
271
417
|
contributed: HookContributions,
|
|
272
418
|
components: SchemaComponents,
|
|
419
|
+
envelopes: Envelopes,
|
|
273
420
|
warn: (message: string) => void,
|
|
274
421
|
): Record<string, ResponseObject> {
|
|
275
|
-
const
|
|
276
|
-
const response = entry.def.schema?.response;
|
|
422
|
+
const answers = new Map<string, Answer[]>();
|
|
277
423
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
424
|
+
const add = (status: string, answer: Answer): void => {
|
|
425
|
+
answers.set(status, [...(answers.get(status) ?? []), answer]);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
for (const [status, schema] of declaredResponses(entry)) {
|
|
429
|
+
const described =
|
|
430
|
+
schema === null ? undefined : emitted(schema, "a response", warn);
|
|
431
|
+
|
|
432
|
+
add(status, {
|
|
433
|
+
description: describeStatus(status),
|
|
434
|
+
placeholder: true,
|
|
435
|
+
schemas: described
|
|
436
|
+
? branchesFor(described, Number(status), envelopes, warn)
|
|
437
|
+
: [],
|
|
438
|
+
});
|
|
291
439
|
}
|
|
292
440
|
|
|
293
|
-
if (!
|
|
294
|
-
|
|
441
|
+
if (![...answers.keys()].some((status) => status.startsWith("2"))) {
|
|
442
|
+
add("200", {
|
|
443
|
+
description: "Successful response",
|
|
444
|
+
placeholder: true,
|
|
445
|
+
schemas: [],
|
|
446
|
+
});
|
|
295
447
|
}
|
|
296
448
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
449
|
+
for (const [status, answer] of failures(
|
|
450
|
+
entry,
|
|
451
|
+
options,
|
|
452
|
+
contributed,
|
|
453
|
+
components,
|
|
454
|
+
envelopes,
|
|
455
|
+
warn,
|
|
456
|
+
)) {
|
|
457
|
+
add(String(status), answer);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const described: Record<string, ResponseObject> = {};
|
|
461
|
+
|
|
462
|
+
for (const [status, list] of answers) {
|
|
463
|
+
described[status] = merge(list, envelopes);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return described;
|
|
308
467
|
}
|
|
309
468
|
|
|
310
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Everything the route can answer with that its own schema does not say:
|
|
471
|
+
* what its hooks declare, and what the framework itself answers.
|
|
472
|
+
*/
|
|
473
|
+
function failures(
|
|
311
474
|
entry: RouteTableEntry,
|
|
312
475
|
options: AppOptions,
|
|
313
476
|
contributed: HookContributions,
|
|
314
|
-
declared: Record<string, ResponseObject>,
|
|
315
477
|
components: SchemaComponents,
|
|
478
|
+
envelopes: Envelopes,
|
|
316
479
|
warn: (message: string) => void,
|
|
317
|
-
):
|
|
480
|
+
): [status: number, answer: Answer][] {
|
|
318
481
|
const schema = entry.def.schema;
|
|
319
|
-
const
|
|
482
|
+
const found: [number, Answer][] = [];
|
|
320
483
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
/** Defines the envelope of one failure once, and refers to it. */
|
|
484
|
+
/**
|
|
485
|
+
* Refers to the definition of one failure: the one of its status and
|
|
486
|
+
* code, or — for a failure whose code only the hook knows and did not
|
|
487
|
+
* declare — one of its own, named after the status.
|
|
488
|
+
*/
|
|
328
489
|
const envelopeRef = (
|
|
329
490
|
status: number,
|
|
330
491
|
error?: string,
|
|
331
492
|
message?: string,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
493
|
+
fields?: DocumentedResponse["fields"],
|
|
494
|
+
): Record<string, unknown> => {
|
|
495
|
+
const described = envelope(status, error, message, fields);
|
|
496
|
+
|
|
497
|
+
const ref =
|
|
498
|
+
error === undefined
|
|
499
|
+
? components.ref(failureName(status), described)
|
|
500
|
+
: envelopes.ref(status, error, described, warn);
|
|
501
|
+
|
|
502
|
+
return ref as unknown as Record<string, unknown>;
|
|
503
|
+
};
|
|
337
504
|
|
|
338
505
|
if (schema?.params || schema?.query || schema?.headers || schema?.body) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
506
|
+
found.push([
|
|
507
|
+
options.validationStatus,
|
|
508
|
+
{
|
|
509
|
+
description: "Request failed schema validation",
|
|
510
|
+
schemas: [
|
|
511
|
+
envelopes.ref(
|
|
512
|
+
options.validationStatus,
|
|
513
|
+
"VALIDATION_FAILED",
|
|
514
|
+
validationFailed(options.validationStatus),
|
|
515
|
+
warn,
|
|
516
|
+
) as unknown as Record<string, unknown>,
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
]);
|
|
346
520
|
}
|
|
347
521
|
|
|
348
522
|
for (const requirement of contributed.security) {
|
|
349
523
|
const status = requirement.status ?? 401;
|
|
350
524
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
525
|
+
found.push([
|
|
526
|
+
status,
|
|
527
|
+
{
|
|
528
|
+
description:
|
|
529
|
+
requirement.description ??
|
|
530
|
+
`Request did not satisfy ${requirement.name}`,
|
|
531
|
+
schemas: [envelopeRef(status, requirement.error, requirement.message)],
|
|
532
|
+
},
|
|
533
|
+
]);
|
|
357
534
|
}
|
|
358
535
|
|
|
359
536
|
for (const response of contributed.responses) {
|
|
@@ -361,52 +538,83 @@ function frameworkFailures(
|
|
|
361
538
|
? emitted(response.schema, "a hook's response", warn)
|
|
362
539
|
: undefined;
|
|
363
540
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
541
|
+
found.push([
|
|
542
|
+
response.status,
|
|
543
|
+
{
|
|
544
|
+
description: response.description,
|
|
545
|
+
schemas: described
|
|
546
|
+
? branchesFor(described, response.status, envelopes, warn)
|
|
547
|
+
: [
|
|
548
|
+
envelopeRef(
|
|
549
|
+
response.status,
|
|
550
|
+
response.error,
|
|
551
|
+
response.message,
|
|
552
|
+
response.fields,
|
|
553
|
+
),
|
|
554
|
+
],
|
|
555
|
+
...(response.headers ? { headers: response.headers } : {}),
|
|
556
|
+
},
|
|
557
|
+
]);
|
|
370
558
|
}
|
|
371
559
|
|
|
372
560
|
if (schema?.body || entry.def.bodyType) {
|
|
373
561
|
const unparsable = parseFailure(entry.def.bodyType);
|
|
374
562
|
|
|
375
563
|
if (unparsable) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
564
|
+
found.push([
|
|
565
|
+
400,
|
|
566
|
+
{
|
|
567
|
+
description: "Body could not be parsed in the declared shape",
|
|
568
|
+
schemas: [envelopeRef(400, unparsable.error, unparsable.message)],
|
|
569
|
+
},
|
|
570
|
+
]);
|
|
380
571
|
}
|
|
381
572
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
573
|
+
found.push([
|
|
574
|
+
413,
|
|
575
|
+
{
|
|
576
|
+
description: "Body exceeded the configured size limit",
|
|
577
|
+
schemas: [
|
|
578
|
+
envelopeRef(
|
|
579
|
+
413,
|
|
580
|
+
"BODY_TOO_LARGE",
|
|
581
|
+
"Body exceeds the configured limit",
|
|
582
|
+
),
|
|
583
|
+
],
|
|
584
|
+
},
|
|
585
|
+
]);
|
|
390
586
|
}
|
|
391
587
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
588
|
+
found.push([
|
|
589
|
+
500,
|
|
590
|
+
{
|
|
591
|
+
description: "The request failed and nothing mapped the failure",
|
|
592
|
+
schemas: [
|
|
593
|
+
envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
|
|
594
|
+
],
|
|
595
|
+
},
|
|
596
|
+
]);
|
|
402
597
|
|
|
403
|
-
return
|
|
598
|
+
return found;
|
|
404
599
|
}
|
|
405
600
|
|
|
406
|
-
/**
|
|
407
|
-
|
|
601
|
+
/**
|
|
602
|
+
* One way a route can answer with a status: what the route declared, what
|
|
603
|
+
* a hook declared, or what the framework answers by itself.
|
|
604
|
+
*
|
|
605
|
+
* `schemas` are the alternatives of the body, each envelope already a
|
|
606
|
+
* reference to its definition; empty when the answer has no body to
|
|
607
|
+
* describe.
|
|
608
|
+
*
|
|
609
|
+
* A route declares a status by its schema alone and has no words for it,
|
|
610
|
+
* so its description is a `placeholder`: it stands for the status only
|
|
611
|
+
* when nothing else describes it.
|
|
612
|
+
*/
|
|
613
|
+
interface Answer {
|
|
408
614
|
readonly description: string;
|
|
409
|
-
readonly
|
|
615
|
+
readonly placeholder?: boolean;
|
|
616
|
+
readonly schemas: readonly Record<string, unknown>[];
|
|
617
|
+
readonly headers?: Readonly<Record<string, HeaderObject>>;
|
|
410
618
|
}
|
|
411
619
|
|
|
412
620
|
/**
|
|
@@ -423,41 +631,74 @@ interface Failure {
|
|
|
423
631
|
* the answer the endpoint is about, and the failures are what can happen
|
|
424
632
|
* to it. Identical bodies are folded together — a `$ref` repeated is one
|
|
425
633
|
* alternative, not two.
|
|
634
|
+
*
|
|
635
|
+
* Headers are gathered the same way, the first description of a name
|
|
636
|
+
* standing for all of them. Descriptions are joined, except a route's
|
|
637
|
+
* placeholder next to one that says something.
|
|
426
638
|
*/
|
|
427
|
-
function merge(
|
|
428
|
-
|
|
429
|
-
list
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
...declaredSchemas(declared),
|
|
438
|
-
...list.map((failure) => failure.schema),
|
|
439
|
-
]);
|
|
440
|
-
|
|
441
|
-
const [only] = schemas;
|
|
639
|
+
function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
|
|
640
|
+
const schemas = distinct(list.flatMap((answer) => answer.schemas));
|
|
641
|
+
const worded = list.filter((answer) => !answer.placeholder);
|
|
642
|
+
const headers: Record<string, HeaderObject> = {};
|
|
643
|
+
|
|
644
|
+
for (const answer of list) {
|
|
645
|
+
for (const [name, header] of Object.entries(answer.headers ?? {})) {
|
|
646
|
+
headers[name] ??= header;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
442
649
|
|
|
443
650
|
return {
|
|
444
|
-
description:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
651
|
+
description: (worded.length > 0 ? worded : list)
|
|
652
|
+
.map((answer) => answer.description)
|
|
653
|
+
.join("; "),
|
|
654
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
655
|
+
...(schemas.length > 0
|
|
656
|
+
? {
|
|
657
|
+
content: {
|
|
658
|
+
"application/json": { schema: unionOf(schemas, envelopes) },
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
: {}),
|
|
448
662
|
};
|
|
449
663
|
}
|
|
450
664
|
|
|
451
665
|
/**
|
|
452
|
-
* The
|
|
453
|
-
*
|
|
666
|
+
* The alternatives of a body as one schema.
|
|
667
|
+
*
|
|
668
|
+
* When every alternative is an envelope, each with its own code, the union
|
|
669
|
+
* says so with a `discriminator` on `error`: a generated client then
|
|
670
|
+
* builds a tagged union and narrows on the code, instead of trying the
|
|
671
|
+
* shapes in turn. The mapping is spelled out, because without it OpenAPI
|
|
672
|
+
* matches the value against the component's name — `Unauthorized`, where
|
|
673
|
+
* the body says `UNAUTHORIZED`.
|
|
454
674
|
*/
|
|
455
|
-
function
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
675
|
+
function unionOf(
|
|
676
|
+
schemas: readonly Record<string, unknown>[],
|
|
677
|
+
envelopes: Envelopes,
|
|
678
|
+
): Record<string, unknown> {
|
|
679
|
+
const [only] = schemas;
|
|
680
|
+
|
|
681
|
+
if (schemas.length === 1 && only) {
|
|
682
|
+
return only;
|
|
683
|
+
}
|
|
459
684
|
|
|
460
|
-
|
|
685
|
+
const mapping: Record<string, string> = {};
|
|
686
|
+
|
|
687
|
+
for (const schema of schemas) {
|
|
688
|
+
const ref = (schema as { $ref?: unknown }).$ref;
|
|
689
|
+
const code = typeof ref === "string" ? envelopes.codeOf(ref) : undefined;
|
|
690
|
+
|
|
691
|
+
if (typeof ref !== "string" || code === undefined) {
|
|
692
|
+
return { anyOf: schemas };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
mapping[code] = ref;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
anyOf: schemas,
|
|
700
|
+
discriminator: { propertyName: "error", mapping },
|
|
701
|
+
};
|
|
461
702
|
}
|
|
462
703
|
|
|
463
704
|
function distinct(
|
|
@@ -478,33 +719,14 @@ function distinct(
|
|
|
478
719
|
});
|
|
479
720
|
}
|
|
480
721
|
|
|
481
|
-
function jsonResponse(
|
|
482
|
-
schema: unknown,
|
|
483
|
-
description: string,
|
|
484
|
-
warn: (message: string) => void,
|
|
485
|
-
): ResponseObject {
|
|
486
|
-
const described = emitted(schema, "a response", warn);
|
|
487
|
-
|
|
488
|
-
if (!described) {
|
|
489
|
-
return { description };
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
return {
|
|
493
|
-
description,
|
|
494
|
-
content: { "application/json": { schema: described } },
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function hasSuccess(declared: Record<string, ResponseObject>): boolean {
|
|
499
|
-
return Object.keys(declared).some((status) => status.startsWith("2"));
|
|
500
|
-
}
|
|
501
|
-
|
|
502
722
|
function describeStatus(status: string): string {
|
|
503
723
|
if (status === "204") {
|
|
504
724
|
return "No content";
|
|
505
725
|
}
|
|
506
726
|
|
|
507
|
-
return status.startsWith("2")
|
|
727
|
+
return status.startsWith("2")
|
|
728
|
+
? "Successful response"
|
|
729
|
+
: errorBody(Number(status)).message;
|
|
508
730
|
}
|
|
509
731
|
|
|
510
732
|
/**
|
|
@@ -518,22 +740,29 @@ function describeStatus(status: string): string {
|
|
|
518
740
|
* The message is documented by example, never as a `const` — it is wording
|
|
519
741
|
* meant for a human, the one field a client must not match on, and the one
|
|
520
742
|
* this project reserves the right to reword.
|
|
743
|
+
*
|
|
744
|
+
* Fields a hook adds come after the three, and never in their place.
|
|
521
745
|
*/
|
|
522
746
|
function envelope(
|
|
523
747
|
status: number,
|
|
524
748
|
error?: string,
|
|
525
749
|
message?: string,
|
|
750
|
+
fields: DocumentedResponse["fields"] = {},
|
|
526
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
|
+
|
|
527
762
|
return {
|
|
528
763
|
type: "object",
|
|
529
|
-
required: ["status", "message", "error"],
|
|
530
|
-
properties: {
|
|
531
|
-
status: { type: "integer", const: status },
|
|
532
|
-
message: message
|
|
533
|
-
? { type: "string", examples: [message] }
|
|
534
|
-
: { type: "string" },
|
|
535
|
-
error: error ? { type: "string", const: error } : { type: "string" },
|
|
536
|
-
},
|
|
764
|
+
required: ["status", "message", "error", ...added],
|
|
765
|
+
properties: { ...fields, ...own },
|
|
537
766
|
};
|
|
538
767
|
}
|
|
539
768
|
|