@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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Error envelopes, one definition per status and code.
3
+ *
4
+ * An envelope reaches a document from four directions: a route's response
5
+ * map, a hook's annotation, a guard's security requirement, and the
6
+ * framework's own failures. Each used to be described where it arrived —
7
+ * a route's inline, the rest by reference — so one `UNAUTHORIZED` could be
8
+ * listed twice under one status, once as a `$ref` and once as the same
9
+ * shape without a name, and a generated client got a named type next to an
10
+ * anonymous twin.
11
+ *
12
+ * Here every envelope, whoever declared it, becomes a reference to the
13
+ * one definition of its status and code. That is also what makes a status
14
+ * discriminable: a client can branch on `error` only if every code under
15
+ * the status has exactly one shape.
16
+ *
17
+ * The route's definition wins. It is the author's own contract — usually
18
+ * the stricter one, a validator's `additionalProperties: false` — where a
19
+ * hook's is the framework's generic envelope. The routes are therefore
20
+ * read first, before any operation is built, so the winner does not depend
21
+ * on which route happens to come first in the table.
22
+ *
23
+ * Internal to the package.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import type { SchemaComponents, SchemaRef } from "./components.ts";
29
+ import { failureName } from "./components.ts";
30
+ import type { JsonSchemaObject } from "./emit.ts";
31
+
32
+ /** The definitions of every envelope of one document. */
33
+ export interface Envelopes {
34
+ /**
35
+ * Records a route's definition, before anything is referenced. The first
36
+ * route to declare a status and code defines it.
37
+ */
38
+ declare(status: number, code: string, schema: JsonSchemaObject): void;
39
+
40
+ /**
41
+ * The reference to the definition of a status and code.
42
+ *
43
+ * `schema` is how the caller describes it. When a definition already
44
+ * exists, that is what is referenced; if the caller's differs from it
45
+ * in fields, `warn` hears about it — once per document, not once per
46
+ * operation the hook runs on.
47
+ */
48
+ ref(
49
+ status: number,
50
+ code: string,
51
+ schema: JsonSchemaObject,
52
+ warn: (message: string) => void,
53
+ ): SchemaRef;
54
+
55
+ /**
56
+ * The code a reference stands for, when it is one this handed out — what
57
+ * a discriminator maps back from.
58
+ */
59
+ codeOf(ref: string): string | undefined;
60
+ }
61
+
62
+ export function envelopes(components: SchemaComponents): Envelopes {
63
+ const declared = new Map<string, JsonSchemaObject>();
64
+ const defined = new Map<string, JsonSchemaObject>();
65
+ const refs = new Map<string, SchemaRef>();
66
+ const codes = new Map<string, string>();
67
+ const names = new Map<string, string>();
68
+ const reported = new Set<string>();
69
+
70
+ return {
71
+ declare(status, code, schema) {
72
+ const key = keyOf(status, code);
73
+
74
+ if (!declared.has(key)) {
75
+ declared.set(key, schema);
76
+ }
77
+ },
78
+
79
+ ref(status, code, schema, warn) {
80
+ const key = keyOf(status, code);
81
+ const definition = declared.get(key) ?? defined.get(key) ?? schema;
82
+
83
+ defined.set(key, definition);
84
+
85
+ const difference = differenceOf(definition, schema);
86
+
87
+ if (difference && !reported.has(`${key} ${difference}`)) {
88
+ reported.add(`${key} ${difference}`);
89
+ warn(
90
+ `the ${status} "${code}" is described by one definition in the document, and a definition that differs from it in ${difference} is not described`,
91
+ );
92
+ }
93
+
94
+ const existing = refs.get(key);
95
+
96
+ if (existing) {
97
+ return existing;
98
+ }
99
+
100
+ const created = components.ref(nameOf(status, code), definition);
101
+
102
+ refs.set(key, created);
103
+ codes.set(created.$ref, code);
104
+
105
+ return created;
106
+ },
107
+
108
+ codeOf(ref) {
109
+ return codes.get(ref);
110
+ },
111
+ };
112
+
113
+ /**
114
+ * The name a status and code are defined under: the code's, unless the
115
+ * same code under another status has it already, in which case the
116
+ * status tells the two apart — `Invalid` and `Invalid409`.
117
+ */
118
+ function nameOf(status: number, code: string): string {
119
+ const name = failureName(status, code);
120
+ const holder = names.get(name);
121
+
122
+ if (holder === undefined || holder === keyOf(status, code)) {
123
+ names.set(name, keyOf(status, code));
124
+
125
+ return name;
126
+ }
127
+
128
+ return `${name}${status}`;
129
+ }
130
+ }
131
+
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
+ /**
169
+ * The alternatives a schema stands for, as a list the status's own union
170
+ * can take in.
171
+ *
172
+ * `anyOf` nested in `anyOf` says nothing its branches do not say side by
173
+ * side, so a schema that is only an `anyOf` is replaced by its branches.
174
+ * A `oneOf` is different — "exactly one" of two overlapping shapes refuses
175
+ * what both accept — and is taken apart only when its branches cannot
176
+ * overlap: envelopes with a different code each. A schema with anything
177
+ * beside its union, a `title` or a `$defs`, is left whole: those keywords
178
+ * belong to the union, and would be lost with it.
179
+ */
180
+ export function branchesOf(schema: JsonSchemaObject): JsonSchemaObject[] {
181
+ const keys = Object.keys(schema);
182
+
183
+ if (keys.length !== 1) {
184
+ return [schema];
185
+ }
186
+
187
+ if (Array.isArray(schema.anyOf)) {
188
+ return (schema.anyOf as JsonSchemaObject[]).flatMap(branchesOf);
189
+ }
190
+
191
+ if (Array.isArray(schema.oneOf)) {
192
+ const branches = (schema.oneOf as JsonSchemaObject[]).flatMap(branchesOf);
193
+ const codes = branches.map(envelopeCode);
194
+
195
+ if (
196
+ codes.every((code) => code !== undefined) &&
197
+ new Set(codes).size === codes.length
198
+ ) {
199
+ return branches;
200
+ }
201
+ }
202
+
203
+ return [schema];
204
+ }
205
+
206
+ function keyOf(status: number, code: string): string {
207
+ return `${status} ${code}`;
208
+ }
209
+
210
+ /**
211
+ * 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.
215
+ */
216
+ function differenceOf(
217
+ kept: JsonSchemaObject,
218
+ other: JsonSchemaObject,
219
+ ): 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);
225
+
226
+ const missing = otherFields.filter((name) => !keptFields.includes(name));
227
+ const extra = keptFields.filter((name) => !otherFields.includes(name));
228
+ const changed = [...missing, ...extra];
229
+
230
+ if (changed.length > 0) {
231
+ return `fields: ${changed.join(", ")}`;
232
+ }
233
+
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)
240
+ ? undefined
241
+ : "which fields are required";
242
+ }
package/src/index.ts CHANGED
@@ -23,13 +23,16 @@ import type { App } from "@tetsujs/core";
23
23
  import { contributionsOf } from "./annotations.ts";
24
24
  import { schemaComponents } from "./components.ts";
25
25
  import type { OpenApiDocument, PathItemObject } from "./document.ts";
26
- import { operationIds, operationOf } from "./operation.ts";
26
+ import { envelopes as envelopeDefinitions } from "./envelopes.ts";
27
+ import { declareEnvelopes, operationIds, operationOf } from "./operation.ts";
27
28
  import { toTemplate } from "./parameters.ts";
28
29
 
29
30
  export type {
31
+ DocumentedHeader,
30
32
  DocumentedResponse,
31
33
  HookContributions,
32
34
  HookDocs,
35
+ SecurityAlternatives,
33
36
  SecurityRequirement,
34
37
  SecurityScheme,
35
38
  } from "./annotations.ts";
@@ -40,12 +43,18 @@ export type {
40
43
  ContentMap,
41
44
  DocumentInfo,
42
45
  DocumentServer,
46
+ HeaderObject,
43
47
  OpenApiDocument,
44
48
  OperationObject,
45
49
  ParameterObject,
46
50
  PathItemObject,
47
51
  ResponseObject,
48
52
  } from "./document.ts";
53
+ export type {
54
+ JsonSchema,
55
+ JsonSchemaKeywords,
56
+ JsonSchemaType,
57
+ } from "./json-schema.ts";
49
58
  export type { DocsAssets, DocsPageOptions, DocsUi } from "./page.ts";
50
59
  export { docsPage } from "./page.ts";
51
60
 
@@ -102,7 +111,15 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
102
111
  const warnings: GeneratorWarning[] = [];
103
112
  const schemes: Record<string, unknown> = {};
104
113
  const components = schemaComponents();
114
+ const envelopes = envelopeDefinitions(components);
105
115
  const ids = operationIds();
116
+ const described = app.entries.filter(
117
+ (entry) => !entry.ws && !entry.def.docs?.hidden,
118
+ );
119
+
120
+ for (const entry of described) {
121
+ declareEnvelopes(entry, envelopes);
122
+ }
106
123
 
107
124
  // Which route already occupies each slot of the document. Two routes the
108
125
  // router keeps apart can still meet here, because a template says less
@@ -113,11 +130,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
113
130
  // that works. It still loses it; it no longer does so without saying.
114
131
  const occupied = new Map<string, string>();
115
132
 
116
- for (const entry of app.entries) {
117
- if (entry.ws || entry.def.docs?.hidden) {
118
- continue;
119
- }
120
-
133
+ for (const entry of described) {
121
134
  const template = toTemplate(entry.path);
122
135
  const route = `${entry.method} ${entry.path}`;
123
136
 
@@ -125,6 +138,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
125
138
  entry,
126
139
  app.options,
127
140
  components,
141
+ envelopes,
128
142
  ids,
129
143
  (message) => {
130
144
  warnings.push({ route, message });
@@ -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
+ }