@tetsujs/openapi 0.4.1 → 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 +63 -3
- package/dist/src/annotations.d.ts +40 -0
- 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 +298 -112
- 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 +43 -0
- 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 +19 -6
- package/src/json-schema.ts +108 -0
- package/src/operation.ts +312 -147
- package/src/page.ts +65 -6
|
@@ -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
|
+
}
|
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 {
|
|
@@ -68,10 +77,83 @@ export function operationOf(
|
|
|
68
77
|
...(contributed.security.length > 0
|
|
69
78
|
? { security: securityOf(contributed.conditions) }
|
|
70
79
|
: {}),
|
|
71
|
-
responses: responses(
|
|
80
|
+
responses: responses(
|
|
81
|
+
entry,
|
|
82
|
+
options,
|
|
83
|
+
contributed,
|
|
84
|
+
components,
|
|
85
|
+
envelopes,
|
|
86
|
+
warn,
|
|
87
|
+
),
|
|
72
88
|
};
|
|
73
89
|
}
|
|
74
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
|
+
|
|
75
157
|
/**
|
|
76
158
|
* The security a route asks for, as OpenAPI spells it.
|
|
77
159
|
*
|
|
@@ -334,90 +416,121 @@ function responses(
|
|
|
334
416
|
options: AppOptions,
|
|
335
417
|
contributed: HookContributions,
|
|
336
418
|
components: SchemaComponents,
|
|
419
|
+
envelopes: Envelopes,
|
|
337
420
|
warn: (message: string) => void,
|
|
338
421
|
): Record<string, ResponseObject> {
|
|
339
|
-
const
|
|
340
|
-
const response = entry.def.schema?.response;
|
|
422
|
+
const answers = new Map<string, Answer[]>();
|
|
341
423
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
});
|
|
355
439
|
}
|
|
356
440
|
|
|
357
|
-
if (!
|
|
358
|
-
|
|
441
|
+
if (![...answers.keys()].some((status) => status.startsWith("2"))) {
|
|
442
|
+
add("200", {
|
|
443
|
+
description: "Successful response",
|
|
444
|
+
placeholder: true,
|
|
445
|
+
schemas: [],
|
|
446
|
+
});
|
|
359
447
|
}
|
|
360
448
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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;
|
|
372
467
|
}
|
|
373
468
|
|
|
374
|
-
|
|
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(
|
|
375
474
|
entry: RouteTableEntry,
|
|
376
475
|
options: AppOptions,
|
|
377
476
|
contributed: HookContributions,
|
|
378
|
-
declared: Record<string, ResponseObject>,
|
|
379
477
|
components: SchemaComponents,
|
|
478
|
+
envelopes: Envelopes,
|
|
380
479
|
warn: (message: string) => void,
|
|
381
|
-
):
|
|
480
|
+
): [status: number, answer: Answer][] {
|
|
382
481
|
const schema = entry.def.schema;
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
const add = (status: number, failure: Failure): void => {
|
|
386
|
-
const key = String(status);
|
|
387
|
-
|
|
388
|
-
failures.set(key, [...(failures.get(key) ?? []), failure]);
|
|
389
|
-
};
|
|
482
|
+
const found: [number, Answer][] = [];
|
|
390
483
|
|
|
391
|
-
/**
|
|
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
|
+
*/
|
|
392
489
|
const envelopeRef = (
|
|
393
490
|
status: number,
|
|
394
491
|
error?: string,
|
|
395
492
|
message?: string,
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
};
|
|
401
504
|
|
|
402
505
|
if (schema?.params || schema?.query || schema?.headers || schema?.body) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
]);
|
|
410
520
|
}
|
|
411
521
|
|
|
412
522
|
for (const requirement of contributed.security) {
|
|
413
523
|
const status = requirement.status ?? 401;
|
|
414
524
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
]);
|
|
421
534
|
}
|
|
422
535
|
|
|
423
536
|
for (const response of contributed.responses) {
|
|
@@ -425,52 +538,83 @@ function frameworkFailures(
|
|
|
425
538
|
? emitted(response.schema, "a hook's response", warn)
|
|
426
539
|
: undefined;
|
|
427
540
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
]);
|
|
434
558
|
}
|
|
435
559
|
|
|
436
560
|
if (schema?.body || entry.def.bodyType) {
|
|
437
561
|
const unparsable = parseFailure(entry.def.bodyType);
|
|
438
562
|
|
|
439
563
|
if (unparsable) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
]);
|
|
444
571
|
}
|
|
445
572
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
]);
|
|
454
586
|
}
|
|
455
587
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
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
|
+
]);
|
|
466
597
|
|
|
467
|
-
return
|
|
598
|
+
return found;
|
|
468
599
|
}
|
|
469
600
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
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 {
|
|
472
614
|
readonly description: string;
|
|
473
|
-
readonly
|
|
615
|
+
readonly placeholder?: boolean;
|
|
616
|
+
readonly schemas: readonly Record<string, unknown>[];
|
|
617
|
+
readonly headers?: Readonly<Record<string, HeaderObject>>;
|
|
474
618
|
}
|
|
475
619
|
|
|
476
620
|
/**
|
|
@@ -487,41 +631,74 @@ interface Failure {
|
|
|
487
631
|
* the answer the endpoint is about, and the failures are what can happen
|
|
488
632
|
* to it. Identical bodies are folded together — a `$ref` repeated is one
|
|
489
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.
|
|
490
638
|
*/
|
|
491
|
-
function merge(
|
|
492
|
-
|
|
493
|
-
list
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
...declaredSchemas(declared),
|
|
502
|
-
...list.map((failure) => failure.schema),
|
|
503
|
-
]);
|
|
504
|
-
|
|
505
|
-
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
|
+
}
|
|
506
649
|
|
|
507
650
|
return {
|
|
508
|
-
description:
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
+
: {}),
|
|
512
662
|
};
|
|
513
663
|
}
|
|
514
664
|
|
|
515
665
|
/**
|
|
516
|
-
* The
|
|
517
|
-
*
|
|
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`.
|
|
518
674
|
*/
|
|
519
|
-
function
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
+
}
|
|
684
|
+
|
|
685
|
+
const mapping: Record<string, string> = {};
|
|
523
686
|
|
|
524
|
-
|
|
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
|
+
};
|
|
525
702
|
}
|
|
526
703
|
|
|
527
704
|
function distinct(
|
|
@@ -542,33 +719,14 @@ function distinct(
|
|
|
542
719
|
});
|
|
543
720
|
}
|
|
544
721
|
|
|
545
|
-
function jsonResponse(
|
|
546
|
-
schema: unknown,
|
|
547
|
-
description: string,
|
|
548
|
-
warn: (message: string) => void,
|
|
549
|
-
): ResponseObject {
|
|
550
|
-
const described = emitted(schema, "a response", warn);
|
|
551
|
-
|
|
552
|
-
if (!described) {
|
|
553
|
-
return { description };
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
return {
|
|
557
|
-
description,
|
|
558
|
-
content: { "application/json": { schema: described } },
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function hasSuccess(declared: Record<string, ResponseObject>): boolean {
|
|
563
|
-
return Object.keys(declared).some((status) => status.startsWith("2"));
|
|
564
|
-
}
|
|
565
|
-
|
|
566
722
|
function describeStatus(status: string): string {
|
|
567
723
|
if (status === "204") {
|
|
568
724
|
return "No content";
|
|
569
725
|
}
|
|
570
726
|
|
|
571
|
-
return status.startsWith("2")
|
|
727
|
+
return status.startsWith("2")
|
|
728
|
+
? "Successful response"
|
|
729
|
+
: errorBody(Number(status)).message;
|
|
572
730
|
}
|
|
573
731
|
|
|
574
732
|
/**
|
|
@@ -582,22 +740,29 @@ function describeStatus(status: string): string {
|
|
|
582
740
|
* The message is documented by example, never as a `const` — it is wording
|
|
583
741
|
* meant for a human, the one field a client must not match on, and the one
|
|
584
742
|
* this project reserves the right to reword.
|
|
743
|
+
*
|
|
744
|
+
* Fields a hook adds come after the three, and never in their place.
|
|
585
745
|
*/
|
|
586
746
|
function envelope(
|
|
587
747
|
status: number,
|
|
588
748
|
error?: string,
|
|
589
749
|
message?: string,
|
|
750
|
+
fields: DocumentedResponse["fields"] = {},
|
|
590
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
|
+
|
|
591
762
|
return {
|
|
592
763
|
type: "object",
|
|
593
|
-
required: ["status", "message", "error"],
|
|
594
|
-
properties: {
|
|
595
|
-
status: { type: "integer", const: status },
|
|
596
|
-
message: message
|
|
597
|
-
? { type: "string", examples: [message] }
|
|
598
|
-
: { type: "string" },
|
|
599
|
-
error: error ? { type: "string", const: error } : { type: "string" },
|
|
600
|
-
},
|
|
764
|
+
required: ["status", "message", "error", ...added],
|
|
765
|
+
properties: { ...fields, ...own },
|
|
601
766
|
};
|
|
602
767
|
}
|
|
603
768
|
|