@tetsujs/openapi 0.4.2 → 0.5.1

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/envelopes.ts CHANGED
@@ -20,6 +20,10 @@
20
20
  * read first, before any operation is built, so the winner does not depend
21
21
  * on which route happens to come first in the table.
22
22
  *
23
+ * What an envelope looks like — how one is described, where its code is,
24
+ * what a client discriminates on — is the document's error format, which
25
+ * the registry carries for everything that builds or reads one.
26
+ *
23
27
  * Internal to the package.
24
28
  *
25
29
  * @module
@@ -28,9 +32,13 @@
28
32
  import type { SchemaComponents, SchemaRef } from "./components.ts";
29
33
  import { failureName } from "./components.ts";
30
34
  import type { JsonSchemaObject } from "./emit.ts";
35
+ import type { ResolvedFormat } from "./errors.ts";
31
36
 
32
37
  /** The definitions of every envelope of one document. */
33
38
  export interface Envelopes {
39
+ /** The document's error format. */
40
+ readonly format: ResolvedFormat;
41
+
34
42
  /**
35
43
  * Records a route's definition, before anything is referenced. The first
36
44
  * route to declare a status and code defines it.
@@ -59,7 +67,10 @@ export interface Envelopes {
59
67
  codeOf(ref: string): string | undefined;
60
68
  }
61
69
 
62
- export function envelopes(components: SchemaComponents): Envelopes {
70
+ export function envelopes(
71
+ components: SchemaComponents,
72
+ format: ResolvedFormat,
73
+ ): Envelopes {
63
74
  const declared = new Map<string, JsonSchemaObject>();
64
75
  const defined = new Map<string, JsonSchemaObject>();
65
76
  const refs = new Map<string, SchemaRef>();
@@ -68,6 +79,8 @@ export function envelopes(components: SchemaComponents): Envelopes {
68
79
  const reported = new Set<string>();
69
80
 
70
81
  return {
82
+ format,
83
+
71
84
  declare(status, code, schema) {
72
85
  const key = keyOf(status, code);
73
86
 
@@ -129,42 +142,6 @@ export function envelopes(components: SchemaComponents): Envelopes {
129
142
  }
130
143
  }
131
144
 
132
- /**
133
- * The error code of a schema that is an envelope: an object whose `error`
134
- * is required and holds one string — a `const`, or an `enum` of one value.
135
- *
136
- * Read off what the validator emitted, not off a declaration: the route
137
- * has no other way to say which of its responses is an envelope, and the
138
- * shape is what a client would branch on anyway.
139
- */
140
- export function envelopeCode(schema: JsonSchemaObject): string | undefined {
141
- const required = Array.isArray(schema.required) ? schema.required : [];
142
-
143
- if (!required.includes("error")) {
144
- return undefined;
145
- }
146
-
147
- const error = (
148
- schema.properties as
149
- | { error?: { const?: unknown; enum?: unknown } }
150
- | undefined
151
- )?.error;
152
-
153
- if (typeof error?.const === "string") {
154
- return error.const;
155
- }
156
-
157
- if (
158
- Array.isArray(error?.enum) &&
159
- error.enum.length === 1 &&
160
- typeof error.enum[0] === "string"
161
- ) {
162
- return error.enum[0];
163
- }
164
-
165
- return undefined;
166
- }
167
-
168
145
  /**
169
146
  * The alternatives a schema stands for, as a list the status's own union
170
147
  * can take in.
@@ -177,20 +154,25 @@ export function envelopeCode(schema: JsonSchemaObject): string | undefined {
177
154
  * beside its union, a `title` or a `$defs`, is left whole: those keywords
178
155
  * belong to the union, and would be lost with it.
179
156
  */
180
- export function branchesOf(schema: JsonSchemaObject): JsonSchemaObject[] {
157
+ export function branchesOf(
158
+ schema: JsonSchemaObject,
159
+ code: (schema: JsonSchemaObject) => string | undefined,
160
+ ): JsonSchemaObject[] {
181
161
  const keys = Object.keys(schema);
182
162
 
183
163
  if (keys.length !== 1) {
184
164
  return [schema];
185
165
  }
186
166
 
167
+ const within = (branch: JsonSchemaObject) => branchesOf(branch, code);
168
+
187
169
  if (Array.isArray(schema.anyOf)) {
188
- return (schema.anyOf as JsonSchemaObject[]).flatMap(branchesOf);
170
+ return (schema.anyOf as JsonSchemaObject[]).flatMap(within);
189
171
  }
190
172
 
191
173
  if (Array.isArray(schema.oneOf)) {
192
- const branches = (schema.oneOf as JsonSchemaObject[]).flatMap(branchesOf);
193
- const codes = branches.map(envelopeCode);
174
+ const branches = (schema.oneOf as JsonSchemaObject[]).flatMap(within);
175
+ const codes = branches.map(code);
194
176
 
195
177
  if (
196
178
  codes.every((code) => code !== undefined) &&
@@ -209,34 +191,78 @@ function keyOf(status: number, code: string): string {
209
191
 
210
192
  /**
211
193
  * How two definitions of one envelope differ in what a client reads: the
212
- * fields one has and the other does not, or which of them are required.
213
- * Wording — a `message` example, `number` against `integer` for the
214
- * status — is not a difference a client would notice, and is not one.
194
+ * fields one has and the other does not, or which of them are required —
195
+ * at any depth, by path, so a format that nests its fields
196
+ * (`error.retryAfter`) is compared as closely as a flat one. Wording — a
197
+ * `message` example, `number` against `integer` for the status — is not a
198
+ * difference a client would notice, and is not one.
215
199
  */
216
200
  function differenceOf(
217
201
  kept: JsonSchemaObject,
218
202
  other: JsonSchemaObject,
219
203
  ): string | undefined {
220
- const fields = (schema: JsonSchemaObject): string[] =>
221
- Object.keys((schema.properties as Record<string, unknown>) ?? {});
222
-
223
- const keptFields = fields(kept);
224
- const otherFields = fields(other);
204
+ const keptShape = shapeOf(kept);
205
+ const otherShape = shapeOf(other);
225
206
 
226
- const missing = otherFields.filter((name) => !keptFields.includes(name));
227
- const extra = keptFields.filter((name) => !otherFields.includes(name));
228
- const changed = [...missing, ...extra];
207
+ const changed = [
208
+ ...otherShape.fields.filter((path) => !keptShape.fields.includes(path)),
209
+ ...keptShape.fields.filter((path) => !otherShape.fields.includes(path)),
210
+ ];
229
211
 
230
212
  if (changed.length > 0) {
231
213
  return `fields: ${changed.join(", ")}`;
232
214
  }
233
215
 
234
- const required = (schema: JsonSchemaObject): string =>
235
- JSON.stringify(
236
- [...(Array.isArray(schema.required) ? schema.required : [])].sort(),
237
- );
238
-
239
- return required(kept) === required(other)
216
+ return JSON.stringify(keptShape.required) ===
217
+ JSON.stringify(otherShape.required)
240
218
  ? undefined
241
219
  : "which fields are required";
242
220
  }
221
+
222
+ /** How deep {@link shapeOf} looks; an envelope is a few levels at most. */
223
+ const shapeDepth = 8;
224
+
225
+ /**
226
+ * The paths of an object schema's fields, and of the required ones among
227
+ * them, following `properties` down. Sorted, so two shapes compare as
228
+ * strings.
229
+ */
230
+ function shapeOf(schema: JsonSchemaObject): {
231
+ fields: string[];
232
+ required: string[];
233
+ } {
234
+ const fields: string[] = [];
235
+ const required: string[] = [];
236
+
237
+ const walk = (node: JsonSchemaObject, prefix: string, depth: number) => {
238
+ const properties = node.properties;
239
+
240
+ if (
241
+ depth > shapeDepth ||
242
+ properties === null ||
243
+ typeof properties !== "object"
244
+ ) {
245
+ return;
246
+ }
247
+
248
+ const requiredHere = Array.isArray(node.required) ? node.required : [];
249
+
250
+ for (const [name, child] of Object.entries(properties)) {
251
+ const path = `${prefix}${name}`;
252
+
253
+ fields.push(path);
254
+
255
+ if (requiredHere.includes(name)) {
256
+ required.push(path);
257
+ }
258
+
259
+ if (child !== null && typeof child === "object") {
260
+ walk(child as JsonSchemaObject, `${path}.`, depth + 1);
261
+ }
262
+ }
263
+ };
264
+
265
+ walk(schema, "", 0);
266
+
267
+ return { fields: fields.sort(), required: required.sort() };
268
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The shape the document gives an error.
3
+ *
4
+ * Every failure an application answers with reaches its `onError` hooks,
5
+ * so one hook sets the format of all of them. The document is generated
6
+ * from the routes, not from that hook, and cannot see what it does: an
7
+ * application with a format of its own says so here too, and the framework's
8
+ * failures, the hooks' refusals and the routes' own envelopes are described
9
+ * in it. Keeping the two in step is the application's to do — the core knows
10
+ * nothing about documents, and a function cannot be read for the shape it
11
+ * returns.
12
+ *
13
+ * Without it the format is the framework's envelope,
14
+ * `{ status, message, error }`.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import type { JsonSchemaObject } from "./emit.ts";
20
+ import type { JsonSchema, JsonSchemaKeywords } from "./json-schema.ts";
21
+
22
+ /**
23
+ * One failure the document describes, as the generator knows it.
24
+ *
25
+ * This is everything known about a failure before it is answered: its
26
+ * status, its code when the framework or the hook knows it, an example of
27
+ * its message, and what else it carries — the `issues` of a validation
28
+ * failure, the `retryAfter` of a rate limit. Where each goes in the body
29
+ * is the format's to decide.
30
+ */
31
+ export interface DocumentedFailure {
32
+ readonly status: number;
33
+
34
+ /** The code a client branches on, when it is known. */
35
+ readonly error?: string;
36
+
37
+ /** An example of the message; wording, never a contract. */
38
+ readonly message?: string;
39
+
40
+ /** What the failure carries besides, as JSON Schema by name. */
41
+ readonly fields: Readonly<Record<string, JsonSchema>>;
42
+ }
43
+
44
+ /** How the application's errors look, for the document to describe them. */
45
+ export interface ErrorFormat {
46
+ /** The body of one failure. */
47
+ readonly schema: (failure: DocumentedFailure) => JsonSchemaKeywords;
48
+
49
+ /**
50
+ * The top-level field that holds the code, for a client to discriminate
51
+ * on — `"code"` in `{ code, message }`. A status whose alternatives are
52
+ * all envelopes then carries a `discriminator` on it.
53
+ *
54
+ * None unless named: OpenAPI discriminates on a top-level field only, and
55
+ * a format that nests its code — `{ error: { code } }` — has none to
56
+ * name.
57
+ */
58
+ readonly discriminator?: string;
59
+
60
+ /**
61
+ * The code of a schema a route or a hook declared, when it is an
62
+ * envelope in this format — how the document knows a route's own `404`
63
+ * is the same failure as a hook's, and gives both one definition.
64
+ *
65
+ * Read off the JSON Schema the validator emitted. By default, the
66
+ * `const` of the discriminator's field, when there is one; a nested
67
+ * format says where its code is.
68
+ */
69
+ readonly code?: (schema: JsonSchemaKeywords) => string | undefined;
70
+ }
71
+
72
+ /** What the generator works with: every part of a format, filled in. */
73
+ export interface ResolvedFormat {
74
+ describe(failure: DocumentedFailure): JsonSchemaObject;
75
+ code(schema: JsonSchemaObject): string | undefined;
76
+ readonly discriminator: string | undefined;
77
+ }
78
+
79
+ /**
80
+ * A format as the generator uses it. Absent, the framework's envelope,
81
+ * discriminated on `error`.
82
+ */
83
+ export function resolveFormat(format: ErrorFormat | undefined): ResolvedFormat {
84
+ if (format === undefined) {
85
+ return {
86
+ describe: envelope,
87
+ code: codeIn("error"),
88
+ discriminator: "error",
89
+ };
90
+ }
91
+
92
+ const discriminator = format.discriminator;
93
+ const read = format.code;
94
+
95
+ return {
96
+ describe: (failure) => format.schema(failure) as JsonSchemaObject,
97
+ code: read
98
+ ? (schema) => read(schema as JsonSchemaKeywords)
99
+ : discriminator === undefined
100
+ ? () => undefined
101
+ : codeIn(discriminator),
102
+ discriminator,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * The body of one failure in the framework's envelope, as JSON Schema.
108
+ *
109
+ * The status is a `const`, and so is the code whenever it is known: a
110
+ * generated client can then discriminate on `error` instead of receiving
111
+ * "an object with three strings", and a reader of the document sees
112
+ * `"BODY_TOO_LARGE"` rather than `string`.
113
+ *
114
+ * The message is documented by example, never as a `const` — it is wording
115
+ * meant for a human, the one field a client must not match on, and the one
116
+ * this project reserves the right to reword.
117
+ *
118
+ * Fields a failure carries besides come after the three, and never in
119
+ * their place.
120
+ */
121
+ function envelope(failure: DocumentedFailure): JsonSchemaObject {
122
+ const own = {
123
+ status: { type: "integer", const: failure.status },
124
+ message: failure.message
125
+ ? { type: "string", examples: [failure.message] }
126
+ : { type: "string" },
127
+ error: failure.error
128
+ ? { type: "string", const: failure.error }
129
+ : { type: "string" },
130
+ };
131
+
132
+ const added = Object.keys(failure.fields).filter(
133
+ (name) => !Object.hasOwn(own, name),
134
+ );
135
+
136
+ return {
137
+ type: "object",
138
+ required: ["status", "message", "error", ...added],
139
+ properties: { ...failure.fields, ...own },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Reads the code of an envelope from one top-level field: required, and
145
+ * holding one string — a `const`, or an `enum` of one value.
146
+ */
147
+ function codeIn(
148
+ field: string,
149
+ ): (schema: JsonSchemaObject) => string | undefined {
150
+ return (schema) => {
151
+ const required = Array.isArray(schema.required) ? schema.required : [];
152
+
153
+ if (!required.includes(field)) {
154
+ return undefined;
155
+ }
156
+
157
+ const value = (
158
+ schema.properties as
159
+ | Record<string, { const?: unknown; enum?: unknown } | undefined>
160
+ | undefined
161
+ )?.[field];
162
+
163
+ if (typeof value?.const === "string") {
164
+ return value.const;
165
+ }
166
+
167
+ if (
168
+ Array.isArray(value?.enum) &&
169
+ value.enum.length === 1 &&
170
+ typeof value.enum[0] === "string"
171
+ ) {
172
+ return value.enum[0];
173
+ }
174
+
175
+ return undefined;
176
+ };
177
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ import { contributionsOf } from "./annotations.ts";
24
24
  import { schemaComponents } from "./components.ts";
25
25
  import type { OpenApiDocument, PathItemObject } from "./document.ts";
26
26
  import { envelopes as envelopeDefinitions } from "./envelopes.ts";
27
+ import type { ErrorFormat } from "./errors.ts";
28
+ import { resolveFormat } from "./errors.ts";
27
29
  import { declareEnvelopes, operationIds, operationOf } from "./operation.ts";
28
30
  import { toTemplate } from "./parameters.ts";
29
31
 
@@ -50,6 +52,7 @@ export type {
50
52
  PathItemObject,
51
53
  ResponseObject,
52
54
  } from "./document.ts";
55
+ export type { DocumentedFailure, ErrorFormat } from "./errors.ts";
53
56
  export type {
54
57
  JsonSchema,
55
58
  JsonSchemaKeywords,
@@ -67,6 +70,33 @@ export interface OpenApiOptions {
67
70
 
68
71
  /** Servers the API is reachable at. */
69
72
  readonly servers?: OpenApiDocument["servers"];
73
+
74
+ /**
75
+ * How the application's errors look, when not like the framework's
76
+ * envelope — for an application whose `onError` hook answers in a format
77
+ * of its own. The framework's failures, the hooks' refusals and the
78
+ * routes' own envelopes are then described in it.
79
+ *
80
+ * @example `{ code, message }`, discriminated on `code`
81
+ * ```ts
82
+ * openapi(app, {
83
+ * info,
84
+ * errors: {
85
+ * schema: ({ error, message, fields }) => ({
86
+ * type: "object",
87
+ * required: ["code", "message", ...Object.keys(fields)],
88
+ * properties: {
89
+ * code: error ? { type: "string", const: error } : { type: "string" },
90
+ * message: { type: "string", ...(message ? { examples: [message] } : {}) },
91
+ * ...fields,
92
+ * },
93
+ * }),
94
+ * discriminator: "code",
95
+ * },
96
+ * });
97
+ * ```
98
+ */
99
+ readonly errors?: ErrorFormat;
70
100
  }
71
101
 
72
102
  /**
@@ -111,7 +141,10 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
111
141
  const warnings: GeneratorWarning[] = [];
112
142
  const schemes: Record<string, unknown> = {};
113
143
  const components = schemaComponents();
114
- const envelopes = envelopeDefinitions(components);
144
+ const envelopes = envelopeDefinitions(
145
+ components,
146
+ resolveFormat(options.errors),
147
+ );
115
148
  const ids = operationIds();
116
149
  const described = app.entries.filter(
117
150
  (entry) => !entry.ws && !entry.def.docs?.hidden,