@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/src/operation.ts CHANGED
@@ -16,17 +16,25 @@ import type {
16
16
  BodyType,
17
17
  RouteTableEntry,
18
18
  } from "@tetsujs/core";
19
- import type { HookContributions } from "./annotations.ts";
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(entry, options, contributed, components, warn),
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 declared: Record<string, ResponseObject> = {};
276
- const response = entry.def.schema?.response;
422
+ const answers = new Map<string, Answer[]>();
277
423
 
278
- if (response && "~standard" in response) {
279
- declared["200"] = jsonResponse(
280
- response as AnySchema,
281
- "Successful response",
282
- warn,
283
- );
284
- } else if (response) {
285
- for (const [status, schema] of Object.entries(response)) {
286
- declared[status] =
287
- schema === null
288
- ? { description: describeStatus(status) }
289
- : jsonResponse(schema, describeStatus(status), warn);
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 (!declared["200"] && !hasSuccess(declared)) {
294
- declared["200"] = { description: "Successful response" };
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
- return {
298
- ...declared,
299
- ...frameworkFailures(
300
- entry,
301
- options,
302
- contributed,
303
- declared,
304
- components,
305
- warn,
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
- function frameworkFailures(
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
- ): Record<string, ResponseObject> {
480
+ ): [status: number, answer: Answer][] {
318
481
  const schema = entry.def.schema;
319
- const failures = new Map<string, Failure[]>();
482
+ const found: [number, Answer][] = [];
320
483
 
321
- const add = (status: number, failure: Failure): void => {
322
- const key = String(status);
323
-
324
- failures.set(key, [...(failures.get(key) ?? []), failure]);
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
- ): Record<string, unknown> =>
333
- components.ref(
334
- failureName(status, error),
335
- envelope(status, error, message),
336
- ) as unknown as Record<string, unknown>;
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
- add(options.validationStatus, {
340
- description: "Request failed schema validation",
341
- schema: components.ref(
342
- failureName(options.validationStatus, "VALIDATION_FAILED"),
343
- validationFailed(options.validationStatus),
344
- ) as unknown as Record<string, unknown>,
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
- add(status, {
352
- description:
353
- requirement.description ??
354
- `Request did not satisfy ${requirement.name}`,
355
- schema: envelopeRef(status, requirement.error, requirement.message),
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
- add(response.status, {
365
- description: response.description,
366
- schema:
367
- described ??
368
- envelopeRef(response.status, response.error, response.message),
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
- add(400, {
377
- description: "Body could not be parsed in the declared shape",
378
- schema: envelopeRef(400, unparsable.error, unparsable.message),
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
- add(413, {
383
- description: "Body exceeded the configured size limit",
384
- schema: envelopeRef(
385
- 413,
386
- "BODY_TOO_LARGE",
387
- "Body exceeds the configured limit",
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
- add(500, {
393
- description: "The request failed and nothing mapped the failure",
394
- schema: envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
395
- });
396
-
397
- const responses: Record<string, ResponseObject> = {};
398
-
399
- for (const [status, list] of failures) {
400
- responses[status] = merge(declared[status], list);
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 responses;
598
+ return found;
404
599
  }
405
600
 
406
- /** One way the framework itself can answer. */
407
- interface Failure {
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 schema: Record<string, unknown>;
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
- declared: ResponseObject | undefined,
429
- list: readonly Failure[],
430
- ): ResponseObject {
431
- const descriptions = [
432
- ...(declared ? [declared.description] : []),
433
- ...list.map((failure) => failure.description),
434
- ];
435
-
436
- const schemas = distinct([
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: descriptions.join("; "),
445
- ...(schemas.length === 1 && only
446
- ? { content: { "application/json": { schema: only } } }
447
- : { content: { "application/json": { schema: { anyOf: schemas } } } }),
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 body a declared response describes, if it describes one: a response
453
- * whose schema could not be emitted contributes its description alone.
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 declaredSchemas(
456
- declared: ResponseObject | undefined,
457
- ): Record<string, unknown>[] {
458
- const schema = declared?.content?.["application/json"]?.schema;
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
- return schema ? [schema] : [];
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") ? "Successful response" : `Response ${status}`;
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