@tetsujs/openapi 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/operation.ts CHANGED
@@ -16,17 +16,26 @@ import type {
16
16
  BodyType,
17
17
  RouteTableEntry,
18
18
  } from "@tetsujs/core";
19
- import type { HookContributions, SecurityRequirement } 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 } from "./envelopes.ts";
38
+ import type { JsonSchema } from "./json-schema.ts";
30
39
  import { partParameters, pathParameters } from "./parameters.ts";
31
40
 
32
41
  /** Describes one route. */
@@ -34,6 +43,7 @@ export function operationOf(
34
43
  entry: RouteTableEntry,
35
44
  options: AppOptions,
36
45
  components: SchemaComponents,
46
+ envelopes: Envelopes,
37
47
  ids: OperationIds,
38
48
  warn: (message: string) => void,
39
49
  ): OperationObject {
@@ -68,10 +78,85 @@ export function operationOf(
68
78
  ...(contributed.security.length > 0
69
79
  ? { security: securityOf(contributed.conditions) }
70
80
  : {}),
71
- responses: responses(entry, options, contributed, components, warn),
81
+ responses: responses(
82
+ entry,
83
+ options,
84
+ contributed,
85
+ components,
86
+ envelopes,
87
+ warn,
88
+ ),
72
89
  };
73
90
  }
74
91
 
92
+ /**
93
+ * Records the envelopes a route declares, so that its definitions are the
94
+ * ones the document keeps — whichever operation refers to them first.
95
+ *
96
+ * Runs before any operation is built. A schema that cannot describe itself
97
+ * is skipped without a word: building the operation says so, once.
98
+ */
99
+ export function declareEnvelopes(
100
+ entry: RouteTableEntry,
101
+ envelopes: Envelopes,
102
+ ): void {
103
+ for (const [status, schema] of declaredResponses(entry)) {
104
+ const described =
105
+ schema === null ? undefined : emitted(schema, "", () => {});
106
+
107
+ const code = envelopes.format.code;
108
+
109
+ for (const branch of described ? branchesOf(described, code) : []) {
110
+ const found = code(branch);
111
+
112
+ if (found !== undefined) {
113
+ envelopes.declare(Number(status), found, branch);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ /** The route's response map as pairs; a single schema is its `200`. */
120
+ function declaredResponses(
121
+ entry: RouteTableEntry,
122
+ ): [status: string, schema: AnySchema | null][] {
123
+ const response = entry.def.schema?.response;
124
+
125
+ if (!response) {
126
+ return [];
127
+ }
128
+
129
+ if ("~standard" in response) {
130
+ return [["200", response as AnySchema]];
131
+ }
132
+
133
+ return Object.entries(response) as [string, AnySchema | null][];
134
+ }
135
+
136
+ /**
137
+ * A described body as the alternatives the document lists: every envelope
138
+ * in it a reference to the one definition of its status and code, and a
139
+ * union taken apart into its branches, so that it joins the other answers
140
+ * of its status rather than nesting inside them.
141
+ */
142
+ function branchesFor(
143
+ schema: JsonSchemaObject,
144
+ status: number,
145
+ envelopes: Envelopes,
146
+ warn: (message: string) => void,
147
+ ): Record<string, unknown>[] {
148
+ return branchesOf(schema, envelopes.format.code).map((branch) => {
149
+ const code = envelopes.format.code(branch);
150
+
151
+ return code === undefined
152
+ ? branch
153
+ : (envelopes.ref(status, code, branch, warn) as unknown as Record<
154
+ string,
155
+ unknown
156
+ >);
157
+ });
158
+ }
159
+
75
160
  /**
76
161
  * The security a route asks for, as OpenAPI spells it.
77
162
  *
@@ -334,90 +419,131 @@ function responses(
334
419
  options: AppOptions,
335
420
  contributed: HookContributions,
336
421
  components: SchemaComponents,
422
+ envelopes: Envelopes,
337
423
  warn: (message: string) => void,
338
424
  ): Record<string, ResponseObject> {
339
- const declared: Record<string, ResponseObject> = {};
340
- const response = entry.def.schema?.response;
425
+ const answers = new Map<string, Answer[]>();
341
426
 
342
- if (response && "~standard" in response) {
343
- declared["200"] = jsonResponse(
344
- response as AnySchema,
345
- "Successful response",
346
- warn,
347
- );
348
- } else if (response) {
349
- for (const [status, schema] of Object.entries(response)) {
350
- declared[status] =
351
- schema === null
352
- ? { description: describeStatus(status) }
353
- : jsonResponse(schema, describeStatus(status), warn);
354
- }
427
+ const add = (status: string, answer: Answer): void => {
428
+ answers.set(status, [...(answers.get(status) ?? []), answer]);
429
+ };
430
+
431
+ for (const [status, schema] of declaredResponses(entry)) {
432
+ const described =
433
+ schema === null ? undefined : emitted(schema, "a response", warn);
434
+
435
+ add(status, {
436
+ description: describeStatus(status),
437
+ placeholder: true,
438
+ schemas: described
439
+ ? branchesFor(described, Number(status), envelopes, warn)
440
+ : [],
441
+ });
355
442
  }
356
443
 
357
- if (!declared["200"] && !hasSuccess(declared)) {
358
- declared["200"] = { description: "Successful response" };
444
+ if (![...answers.keys()].some((status) => status.startsWith("2"))) {
445
+ add("200", {
446
+ description: "Successful response",
447
+ placeholder: true,
448
+ schemas: [],
449
+ });
359
450
  }
360
451
 
361
- return {
362
- ...declared,
363
- ...frameworkFailures(
364
- entry,
365
- options,
366
- contributed,
367
- declared,
368
- components,
369
- warn,
370
- ),
371
- };
452
+ for (const [status, answer] of failures(
453
+ entry,
454
+ options,
455
+ contributed,
456
+ components,
457
+ envelopes,
458
+ warn,
459
+ )) {
460
+ add(String(status), answer);
461
+ }
462
+
463
+ const described: Record<string, ResponseObject> = {};
464
+
465
+ for (const [status, list] of answers) {
466
+ described[status] = merge(list, envelopes);
467
+ }
468
+
469
+ return described;
372
470
  }
373
471
 
374
- function frameworkFailures(
472
+ /**
473
+ * Everything the route can answer with that its own schema does not say:
474
+ * what its hooks declare, and what the framework itself answers.
475
+ */
476
+ function failures(
375
477
  entry: RouteTableEntry,
376
478
  options: AppOptions,
377
479
  contributed: HookContributions,
378
- declared: Record<string, ResponseObject>,
379
480
  components: SchemaComponents,
481
+ envelopes: Envelopes,
380
482
  warn: (message: string) => void,
381
- ): Record<string, ResponseObject> {
483
+ ): [status: number, answer: Answer][] {
382
484
  const schema = entry.def.schema;
383
- const failures = new Map<string, Failure[]>();
384
-
385
- const add = (status: number, failure: Failure): void => {
386
- const key = String(status);
485
+ const found: [number, Answer][] = [];
387
486
 
388
- failures.set(key, [...(failures.get(key) ?? []), failure]);
389
- };
390
-
391
- /** Defines the envelope of one failure once, and refers to it. */
487
+ /**
488
+ * Refers to the definition of one failure: the one of its status and
489
+ * code, or — for a failure whose code only the hook knows and did not
490
+ * declare — one of its own, named after the status.
491
+ */
392
492
  const envelopeRef = (
393
493
  status: number,
394
494
  error?: string,
395
495
  message?: string,
396
- ): Record<string, unknown> =>
397
- components.ref(
398
- failureName(status, error),
399
- envelope(status, error, message),
400
- ) as unknown as Record<string, unknown>;
496
+ fields?: DocumentedResponse["fields"],
497
+ ): Record<string, unknown> => {
498
+ const described = envelopes.format.describe({
499
+ status,
500
+ ...(error === undefined ? {} : { error }),
501
+ ...(message === undefined ? {} : { message }),
502
+ fields: fields ?? {},
503
+ });
504
+
505
+ const ref =
506
+ error === undefined
507
+ ? components.ref(failureName(status), described)
508
+ : envelopes.ref(status, error, described, warn);
509
+
510
+ return ref as unknown as Record<string, unknown>;
511
+ };
401
512
 
402
513
  if (schema?.params || schema?.query || schema?.headers || schema?.body) {
403
- add(options.validationStatus, {
404
- description: "Request failed schema validation",
405
- schema: components.ref(
406
- failureName(options.validationStatus, "VALIDATION_FAILED"),
407
- validationFailed(options.validationStatus),
408
- ) as unknown as Record<string, unknown>,
409
- });
514
+ found.push([
515
+ options.validationStatus,
516
+ {
517
+ description: "Request failed schema validation",
518
+ schemas: [
519
+ envelopes.ref(
520
+ options.validationStatus,
521
+ "VALIDATION_FAILED",
522
+ envelopes.format.describe({
523
+ status: options.validationStatus,
524
+ error: "VALIDATION_FAILED",
525
+ message: "Validation failed",
526
+ fields: { issues },
527
+ }),
528
+ warn,
529
+ ) as unknown as Record<string, unknown>,
530
+ ],
531
+ },
532
+ ]);
410
533
  }
411
534
 
412
535
  for (const requirement of contributed.security) {
413
536
  const status = requirement.status ?? 401;
414
537
 
415
- add(status, {
416
- description:
417
- requirement.description ??
418
- `Request did not satisfy ${requirement.name}`,
419
- schema: envelopeRef(status, requirement.error, requirement.message),
420
- });
538
+ found.push([
539
+ status,
540
+ {
541
+ description:
542
+ requirement.description ??
543
+ `Request did not satisfy ${requirement.name}`,
544
+ schemas: [envelopeRef(status, requirement.error, requirement.message)],
545
+ },
546
+ ]);
421
547
  }
422
548
 
423
549
  for (const response of contributed.responses) {
@@ -425,52 +551,83 @@ function frameworkFailures(
425
551
  ? emitted(response.schema, "a hook's response", warn)
426
552
  : undefined;
427
553
 
428
- add(response.status, {
429
- description: response.description,
430
- schema:
431
- described ??
432
- envelopeRef(response.status, response.error, response.message),
433
- });
554
+ found.push([
555
+ response.status,
556
+ {
557
+ description: response.description,
558
+ schemas: described
559
+ ? branchesFor(described, response.status, envelopes, warn)
560
+ : [
561
+ envelopeRef(
562
+ response.status,
563
+ response.error,
564
+ response.message,
565
+ response.fields,
566
+ ),
567
+ ],
568
+ ...(response.headers ? { headers: response.headers } : {}),
569
+ },
570
+ ]);
434
571
  }
435
572
 
436
573
  if (schema?.body || entry.def.bodyType) {
437
574
  const unparsable = parseFailure(entry.def.bodyType);
438
575
 
439
576
  if (unparsable) {
440
- add(400, {
441
- description: "Body could not be parsed in the declared shape",
442
- schema: envelopeRef(400, unparsable.error, unparsable.message),
443
- });
577
+ found.push([
578
+ 400,
579
+ {
580
+ description: "Body could not be parsed in the declared shape",
581
+ schemas: [envelopeRef(400, unparsable.error, unparsable.message)],
582
+ },
583
+ ]);
444
584
  }
445
585
 
446
- add(413, {
447
- description: "Body exceeded the configured size limit",
448
- schema: envelopeRef(
449
- 413,
450
- "BODY_TOO_LARGE",
451
- "Body exceeds the configured limit",
452
- ),
453
- });
586
+ found.push([
587
+ 413,
588
+ {
589
+ description: "Body exceeded the configured size limit",
590
+ schemas: [
591
+ envelopeRef(
592
+ 413,
593
+ "BODY_TOO_LARGE",
594
+ "Body exceeds the configured limit",
595
+ ),
596
+ ],
597
+ },
598
+ ]);
454
599
  }
455
600
 
456
- add(500, {
457
- description: "The request failed and nothing mapped the failure",
458
- schema: envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
459
- });
460
-
461
- const responses: Record<string, ResponseObject> = {};
462
-
463
- for (const [status, list] of failures) {
464
- responses[status] = merge(declared[status], list);
465
- }
601
+ found.push([
602
+ 500,
603
+ {
604
+ description: "The request failed and nothing mapped the failure",
605
+ schemas: [
606
+ envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error"),
607
+ ],
608
+ },
609
+ ]);
466
610
 
467
- return responses;
611
+ return found;
468
612
  }
469
613
 
470
- /** One way the framework itself can answer. */
471
- interface Failure {
614
+ /**
615
+ * One way a route can answer with a status: what the route declared, what
616
+ * a hook declared, or what the framework answers by itself.
617
+ *
618
+ * `schemas` are the alternatives of the body, each envelope already a
619
+ * reference to its definition; empty when the answer has no body to
620
+ * describe.
621
+ *
622
+ * A route declares a status by its schema alone and has no words for it,
623
+ * so its description is a `placeholder`: it stands for the status only
624
+ * when nothing else describes it.
625
+ */
626
+ interface Answer {
472
627
  readonly description: string;
473
- readonly schema: Record<string, unknown>;
628
+ readonly placeholder?: boolean;
629
+ readonly schemas: readonly Record<string, unknown>[];
630
+ readonly headers?: Readonly<Record<string, HeaderObject>>;
474
631
  }
475
632
 
476
633
  /**
@@ -487,41 +644,81 @@ interface Failure {
487
644
  * the answer the endpoint is about, and the failures are what can happen
488
645
  * to it. Identical bodies are folded together — a `$ref` repeated is one
489
646
  * alternative, not two.
647
+ *
648
+ * Headers are gathered the same way, the first description of a name
649
+ * standing for all of them. Descriptions are joined, except a route's
650
+ * placeholder next to one that says something.
490
651
  */
491
- function merge(
492
- declared: ResponseObject | undefined,
493
- list: readonly Failure[],
494
- ): ResponseObject {
495
- const descriptions = [
496
- ...(declared ? [declared.description] : []),
497
- ...list.map((failure) => failure.description),
498
- ];
499
-
500
- const schemas = distinct([
501
- ...declaredSchemas(declared),
502
- ...list.map((failure) => failure.schema),
503
- ]);
504
-
505
- const [only] = schemas;
652
+ function merge(list: readonly Answer[], envelopes: Envelopes): ResponseObject {
653
+ const schemas = distinct(list.flatMap((answer) => answer.schemas));
654
+ const worded = list.filter((answer) => !answer.placeholder);
655
+ const headers: Record<string, HeaderObject> = {};
656
+
657
+ for (const answer of list) {
658
+ for (const [name, header] of Object.entries(answer.headers ?? {})) {
659
+ headers[name] ??= header;
660
+ }
661
+ }
506
662
 
507
663
  return {
508
- description: descriptions.join("; "),
509
- ...(schemas.length === 1 && only
510
- ? { content: { "application/json": { schema: only } } }
511
- : { content: { "application/json": { schema: { anyOf: schemas } } } }),
664
+ description: (worded.length > 0 ? worded : list)
665
+ .map((answer) => answer.description)
666
+ .join("; "),
667
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
668
+ ...(schemas.length > 0
669
+ ? {
670
+ content: {
671
+ "application/json": { schema: unionOf(schemas, envelopes) },
672
+ },
673
+ }
674
+ : {}),
512
675
  };
513
676
  }
514
677
 
515
678
  /**
516
- * The body a declared response describes, if it describes one: a response
517
- * whose schema could not be emitted contributes its description alone.
679
+ * The alternatives of a body as one schema.
680
+ *
681
+ * When every alternative is an envelope, each with its own code, the union
682
+ * says so with a `discriminator` on `error`: a generated client then
683
+ * builds a tagged union and narrows on the code, instead of trying the
684
+ * shapes in turn. The mapping is spelled out, because without it OpenAPI
685
+ * matches the value against the component's name — `Unauthorized`, where
686
+ * the body says `UNAUTHORIZED`. The field is the error format's; a format
687
+ * that names none — its code nested below the top — has no discriminator.
518
688
  */
519
- function declaredSchemas(
520
- declared: ResponseObject | undefined,
521
- ): Record<string, unknown>[] {
522
- const schema = declared?.content?.["application/json"]?.schema;
689
+ function unionOf(
690
+ schemas: readonly Record<string, unknown>[],
691
+ envelopes: Envelopes,
692
+ ): Record<string, unknown> {
693
+ const [only] = schemas;
523
694
 
524
- return schema ? [schema] : [];
695
+ if (schemas.length === 1 && only) {
696
+ return only;
697
+ }
698
+
699
+ const propertyName = envelopes.format.discriminator;
700
+
701
+ if (propertyName === undefined) {
702
+ return { anyOf: schemas };
703
+ }
704
+
705
+ const mapping: Record<string, string> = {};
706
+
707
+ for (const schema of schemas) {
708
+ const ref = (schema as { $ref?: unknown }).$ref;
709
+ const code = typeof ref === "string" ? envelopes.codeOf(ref) : undefined;
710
+
711
+ if (typeof ref !== "string" || code === undefined) {
712
+ return { anyOf: schemas };
713
+ }
714
+
715
+ mapping[code] = ref;
716
+ }
717
+
718
+ return {
719
+ anyOf: schemas,
720
+ discriminator: { propertyName, mapping },
721
+ };
525
722
  }
526
723
 
527
724
  function distinct(
@@ -542,88 +739,32 @@ function distinct(
542
739
  });
543
740
  }
544
741
 
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
742
  function describeStatus(status: string): string {
567
743
  if (status === "204") {
568
744
  return "No content";
569
745
  }
570
746
 
571
- return status.startsWith("2") ? "Successful response" : `Response ${status}`;
747
+ return status.startsWith("2")
748
+ ? "Successful response"
749
+ : errorBody(Number(status)).message;
572
750
  }
573
751
 
574
752
  /**
575
- * The body of one framework-produced failure, as JSON Schema.
576
- *
577
- * The status is a `const`, and so is the code whenever the framework knows
578
- * which one it will be: a generated client can then discriminate on
579
- * `error` instead of receiving "an object with three strings", and a
580
- * reader of the document sees `"BODY_TOO_LARGE"` rather than `string`.
581
- *
582
- * The message is documented by example, never as a `const` — it is wording
583
- * meant for a human, the one field a client must not match on, and the one
584
- * this project reserves the right to reword.
753
+ * What a validation failure carries besides the code and the message: every
754
+ * issue, with where it is. Handed to the error format as a field, for the
755
+ * format to place.
585
756
  */
586
- function envelope(
587
- status: number,
588
- error?: string,
589
- message?: string,
590
- ): JsonSchemaObject {
591
- return {
757
+ const issues: JsonSchema = {
758
+ type: "array",
759
+ items: {
592
760
  type: "object",
593
- required: ["status", "message", "error"],
761
+ required: ["message", "path"],
594
762
  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" },
763
+ message: { type: "string" },
764
+ path: { type: "array", items: { type: ["string", "number"] } },
600
765
  },
601
- };
602
- }
603
-
604
- /** The validation envelope: the standard three fields, plus the issues. */
605
- function validationFailed(status: number): Record<string, unknown> {
606
- const base = envelope(status, "VALIDATION_FAILED", "Validation failed");
607
-
608
- return {
609
- ...base,
610
- required: ["status", "message", "error", "issues"],
611
- properties: {
612
- ...(base.properties as Record<string, unknown>),
613
- issues: {
614
- type: "array",
615
- items: {
616
- type: "object",
617
- required: ["message", "path"],
618
- properties: {
619
- message: { type: "string" },
620
- path: { type: "array", items: { type: ["string", "number"] } },
621
- },
622
- },
623
- },
624
- },
625
- };
626
- }
766
+ },
767
+ };
627
768
 
628
769
  /**
629
770
  * How a body of this type fails to parse — or nothing, for a text or a