@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 @@
|
|
|
1
|
+
{"version":3,"file":"json-schema.d.ts","sourceRoot":"","sources":["../../src/json-schema.ts"],"names":[],"mappings":"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"}
|
package/dist/src/operation.d.ts
CHANGED
|
@@ -12,8 +12,17 @@
|
|
|
12
12
|
import type { AppOptions, RouteTableEntry } from "@tetsujs/core";
|
|
13
13
|
import type { SchemaComponents } from "./components.ts";
|
|
14
14
|
import type { OperationObject } from "./document.ts";
|
|
15
|
+
import type { Envelopes } from "./envelopes.ts";
|
|
15
16
|
/** Describes one route. */
|
|
16
|
-
export declare function operationOf(entry: RouteTableEntry, options: AppOptions, components: SchemaComponents, ids: OperationIds, warn: (message: string) => void): OperationObject;
|
|
17
|
+
export declare function operationOf(entry: RouteTableEntry, options: AppOptions, components: SchemaComponents, envelopes: Envelopes, ids: OperationIds, warn: (message: string) => void): OperationObject;
|
|
18
|
+
/**
|
|
19
|
+
* Records the envelopes a route declares, so that its definitions are the
|
|
20
|
+
* ones the document keeps — whichever operation refers to them first.
|
|
21
|
+
*
|
|
22
|
+
* Runs before any operation is built. A schema that cannot describe itself
|
|
23
|
+
* is skipped without a word: building the operation says so, once.
|
|
24
|
+
*/
|
|
25
|
+
export declare function declareEnvelopes(entry: RouteTableEntry, envelopes: Envelopes): void;
|
|
17
26
|
/**
|
|
18
27
|
* Hands out the `operationId` of every route in one document.
|
|
19
28
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"operation.d.ts","sourceRoot":"","sources":["../../src/operation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAEV,UAAU,EAEV,eAAe,EAChB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"operation.d.ts","sourceRoot":"","sources":["../../src/operation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAEV,UAAU,EAEV,eAAe,EAChB,MAAM,eAAe,CAAC;AAQvB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD,OAAO,KAAK,EAGV,eAAe,EAEhB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAIhD,2BAA2B;AAC3B,wBAAgB,WAAW,CACzB,KAAK,EAAE,eAAe,EACtB,OAAO,EAAE,UAAU,EACnB,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,SAAS,EACpB,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAC9B,eAAe,CAyCjB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,SAAS,GACnB,IAAI,CAaN;AA+GD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC;CACtC;AAED,wBAAgB,YAAY,IAAI,YAAY,CAmB3C"}
|
package/dist/src/page.d.ts
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* a parameter, so a self-hosted copy is one option away. Nothing is
|
|
13
13
|
* fetched unless this function is called.
|
|
14
14
|
*
|
|
15
|
+
* What the CDN serves runs on the application's origin, with its cookies.
|
|
16
|
+
* The default renderers are therefore pinned to an exact version and
|
|
17
|
+
* carry the hash of that file: a CDN, or a package release, that serves
|
|
18
|
+
* something else is refused by the browser rather than run. A page on an
|
|
19
|
+
* origin with a session is still somebody else's application in the
|
|
20
|
+
* user's session — which is why `docs()` can serve the document alone.
|
|
21
|
+
*
|
|
15
22
|
* @module
|
|
16
23
|
*/
|
|
17
24
|
/** The renderers this package knows how to bootstrap. */
|
|
@@ -20,6 +27,19 @@ export type DocsUi = "scalar" | "swagger-ui" | "redoc";
|
|
|
20
27
|
export interface DocsAssets {
|
|
21
28
|
readonly script: string;
|
|
22
29
|
readonly style?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Subresource Integrity hashes of the two files, for a copy that is not
|
|
32
|
+
* on the application's own origin: the browser runs the file only if it
|
|
33
|
+
* hashes to this. Absent, the files are loaded as they are.
|
|
34
|
+
*
|
|
35
|
+
* One line computes one:
|
|
36
|
+
* `curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A`,
|
|
37
|
+
* prefixed with `sha384-`.
|
|
38
|
+
*/
|
|
39
|
+
readonly integrity?: {
|
|
40
|
+
readonly script?: string;
|
|
41
|
+
readonly style?: string;
|
|
42
|
+
};
|
|
23
43
|
}
|
|
24
44
|
/** What the page needs to know. */
|
|
25
45
|
export interface DocsPageOptions {
|
package/dist/src/page.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/page.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,yDAAyD;AACzD,MAAM,MAAM,MAAM,GAAG,QAAQ,GAAG,YAAY,GAAG,OAAO,CAAC;AAEvD,uCAAuC;AACvC,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE;QACnB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAED,mCAAmC;AACnC,MAAM,WAAW,eAAe;IAC9B,mCAAmC;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,qEAAqE;IACrE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAE7B,wDAAwD;IACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAExB,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC;CAC9B;AAyCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAyBzD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tetsujs/openapi",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "An OpenAPI 3.1 document and docs page generated from Tetsu routes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@tetsujs/core": "^0.4.
|
|
37
|
+
"@tetsujs/core": "^0.4.2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@tetsujs/core": "0.4.
|
|
40
|
+
"@tetsujs/core": "0.4.2"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|
package/src/annotations.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { AnyHook, AnySchema } from "@tetsujs/core";
|
|
20
|
+
import type { JsonSchema } from "./json-schema.ts";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The definition of a scheme, as OpenAPI spells it.
|
|
@@ -83,6 +84,48 @@ export interface DocumentedResponse {
|
|
|
83
84
|
|
|
84
85
|
/** An example of the envelope's `message`, for the same reason. */
|
|
85
86
|
readonly message?: string;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fields the hook adds to the envelope, as JSON Schema by name — the
|
|
90
|
+
* `retryAfter` of `{ ...errorBody(429, "RATE_LIMITED"), retryAfter }`.
|
|
91
|
+
*
|
|
92
|
+
* Each is documented as always present. The envelope's own `status`,
|
|
93
|
+
* `message` and `error` are not redefined by a field of the same name:
|
|
94
|
+
* `error` is what a client discriminates on, and it stays the code the
|
|
95
|
+
* hook declared. Ignored when the response has a `schema`, which says
|
|
96
|
+
* everything about the body itself.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* documented(limiter, {
|
|
101
|
+
* responses: [
|
|
102
|
+
* {
|
|
103
|
+
* status: 429,
|
|
104
|
+
* description: "Too many requests",
|
|
105
|
+
* error: "RATE_LIMITED",
|
|
106
|
+
* fields: { retryAfter: { type: "integer", minimum: 0 } },
|
|
107
|
+
* },
|
|
108
|
+
* ],
|
|
109
|
+
* });
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
readonly fields?: Readonly<Record<string, JsonSchema>>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Headers the hook sets on this response, by name — the `retry-after`
|
|
116
|
+
* of a refusal.
|
|
117
|
+
*
|
|
118
|
+
* Documented as possible rather than required: a status several sources
|
|
119
|
+
* answer with is one response in the document, and a header one of them
|
|
120
|
+
* sets is not on the others'.
|
|
121
|
+
*/
|
|
122
|
+
readonly headers?: Readonly<Record<string, DocumentedHeader>>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A header a hook's response carries. */
|
|
126
|
+
export interface DocumentedHeader {
|
|
127
|
+
readonly description?: string;
|
|
128
|
+
readonly schema: JsonSchema;
|
|
86
129
|
}
|
|
87
130
|
|
|
88
131
|
/**
|
package/src/controller.ts
CHANGED
|
@@ -22,7 +22,11 @@ import type { DocsAssets, DocsUi } from "./page.ts";
|
|
|
22
22
|
import { docsPage } from "./page.ts";
|
|
23
23
|
|
|
24
24
|
/** What the documentation controller serves, and from where. */
|
|
25
|
-
export interface DocsOptions<
|
|
25
|
+
export interface DocsOptions<
|
|
26
|
+
Path extends string,
|
|
27
|
+
UiPath extends string,
|
|
28
|
+
Ui extends DocsUi | false = DocsUi | false,
|
|
29
|
+
> {
|
|
26
30
|
/** Title, version and the rest of the document's `info` block. */
|
|
27
31
|
readonly info: OpenApiDocument["info"];
|
|
28
32
|
|
|
@@ -35,8 +39,16 @@ export interface DocsOptions<Path extends string, UiPath extends string> {
|
|
|
35
39
|
/** Where the page is served. Defaults to `/docs`. */
|
|
36
40
|
readonly uiPath?: UiPath & ValidatePath<UiPath>;
|
|
37
41
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Which renderer the page bootstraps. Defaults to `"scalar"`.
|
|
44
|
+
*
|
|
45
|
+
* `false` serves the document without a page — what production wants
|
|
46
|
+
* on an origin that carries a session. The page runs its renderer on the
|
|
47
|
+
* application's origin, so a renderer that is not what it should be acts
|
|
48
|
+
* in the signed-in user's name there; the document alone runs nothing.
|
|
49
|
+
* `uiPath`, `title` and `assets` are then unused.
|
|
50
|
+
*/
|
|
51
|
+
readonly ui?: Ui;
|
|
40
52
|
|
|
41
53
|
/** Browser title of the page. Defaults to the document's own title. */
|
|
42
54
|
readonly title?: string;
|
|
@@ -69,20 +81,27 @@ export interface DocsOptions<Path extends string, UiPath extends string> {
|
|
|
69
81
|
* Instantiated through the factory, never directly — the routes carry the
|
|
70
82
|
* configured paths in their types, which only the factory can infer.
|
|
71
83
|
*/
|
|
72
|
-
export class DocsController<
|
|
84
|
+
export class DocsController<
|
|
85
|
+
Path extends string,
|
|
86
|
+
UiPath extends string,
|
|
87
|
+
Page extends boolean = true,
|
|
88
|
+
> {
|
|
73
89
|
/** The document itself, as `application/json`. */
|
|
74
90
|
readonly json: RouteDef<Path, SchemaConfig, never, undefined, "GET">;
|
|
75
91
|
|
|
76
|
-
/** The page that renders it
|
|
77
|
-
readonly ui:
|
|
92
|
+
/** The page that renders it — none with `ui: false`. */
|
|
93
|
+
readonly ui: Page extends true
|
|
94
|
+
? RouteDef<UiPath, SchemaConfig, never, undefined, "GET">
|
|
95
|
+
: undefined;
|
|
78
96
|
|
|
79
97
|
private document: OpenApiDocument | undefined;
|
|
80
98
|
|
|
99
|
+
private page: string | undefined;
|
|
100
|
+
|
|
81
101
|
constructor(
|
|
82
102
|
private readonly options: DocsOptions<Path, UiPath>,
|
|
83
103
|
documentPath: string,
|
|
84
104
|
uiPath: string,
|
|
85
|
-
page: string,
|
|
86
105
|
) {
|
|
87
106
|
const hidden = !options.documentSelf;
|
|
88
107
|
|
|
@@ -98,7 +117,7 @@ export class DocsController<Path extends string, UiPath extends string> {
|
|
|
98
117
|
path: "/docs",
|
|
99
118
|
docs: { summary: "Rendered documentation", tags: ["docs"], hidden },
|
|
100
119
|
handler: () =>
|
|
101
|
-
new Response(page, {
|
|
120
|
+
new Response(this.page, {
|
|
102
121
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
103
122
|
}),
|
|
104
123
|
});
|
|
@@ -113,10 +132,20 @@ export class DocsController<Path extends string, UiPath extends string> {
|
|
|
113
132
|
* configured path lands at runtime.
|
|
114
133
|
*/
|
|
115
134
|
this.json = { ...json, path: documentPath } as unknown as this["json"];
|
|
116
|
-
this.ui =
|
|
135
|
+
this.ui = (
|
|
136
|
+
options.ui === false ? undefined : { ...ui, path: uiPath }
|
|
137
|
+
) as this["ui"];
|
|
117
138
|
}
|
|
118
139
|
|
|
119
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Generates the document of the application this was mounted in, and
|
|
142
|
+
* the page that renders it.
|
|
143
|
+
*
|
|
144
|
+
* The page is built here rather than with the routes because only the
|
|
145
|
+
* application knows where the document ended up: a group this controller
|
|
146
|
+
* is mounted under prefixes its path, and a page pointing at the path as
|
|
147
|
+
* configured would fetch a `404`.
|
|
148
|
+
*/
|
|
120
149
|
[onMount](app: App): void {
|
|
121
150
|
const generated = openapi(app, {
|
|
122
151
|
info: this.options.info,
|
|
@@ -125,6 +154,19 @@ export class DocsController<Path extends string, UiPath extends string> {
|
|
|
125
154
|
|
|
126
155
|
this.document = generated.document;
|
|
127
156
|
|
|
157
|
+
const ui = this.options.ui ?? "scalar";
|
|
158
|
+
const json: unknown = this.json;
|
|
159
|
+
const served = app.entries.find((entry) => entry.def === json);
|
|
160
|
+
|
|
161
|
+
if (ui !== false && served) {
|
|
162
|
+
this.page = docsPage({
|
|
163
|
+
ui,
|
|
164
|
+
documentUrl: served.path,
|
|
165
|
+
title: this.options.title ?? this.options.info.title,
|
|
166
|
+
...(this.options.assets ? { assets: this.options.assets } : {}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
128
170
|
const report = this.options.onWarning ?? warn;
|
|
129
171
|
|
|
130
172
|
for (const warning of generated.warnings) {
|
|
@@ -156,18 +198,15 @@ export class DocsController<Path extends string, UiPath extends string> {
|
|
|
156
198
|
export function docs<
|
|
157
199
|
const Path extends string = "/openapi.json",
|
|
158
200
|
const UiPath extends string = "/docs",
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
return new DocsController(options, documentPath, uiPath, page);
|
|
201
|
+
const Ui extends DocsUi | false = "scalar",
|
|
202
|
+
>(
|
|
203
|
+
options: DocsOptions<Path, UiPath, Ui>,
|
|
204
|
+
): DocsController<Path, UiPath, Ui extends false ? false : true> {
|
|
205
|
+
return new DocsController(
|
|
206
|
+
options,
|
|
207
|
+
options.path ?? "/openapi.json",
|
|
208
|
+
options.uiPath ?? "/docs",
|
|
209
|
+
) as DocsController<Path, UiPath, Ui extends false ? false : true>;
|
|
171
210
|
}
|
|
172
211
|
|
|
173
212
|
function warn(warning: GeneratorWarning): void {
|
package/src/document.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* @module
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import type { JsonSchema } from "./json-schema.ts";
|
|
19
|
+
|
|
18
20
|
/** Metadata every OpenAPI document must carry. */
|
|
19
21
|
export interface DocumentInfo {
|
|
20
22
|
readonly title: string;
|
|
@@ -44,9 +46,16 @@ export type ContentMap = Record<
|
|
|
44
46
|
{ readonly schema?: Record<string, unknown> }
|
|
45
47
|
>;
|
|
46
48
|
|
|
49
|
+
/** A header a response carries. */
|
|
50
|
+
export interface HeaderObject {
|
|
51
|
+
readonly description?: string;
|
|
52
|
+
readonly schema: JsonSchema;
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
/** One response of an operation. */
|
|
48
56
|
export interface ResponseObject {
|
|
49
57
|
readonly description: string;
|
|
58
|
+
readonly headers?: Record<string, HeaderObject>;
|
|
50
59
|
readonly content?: ContentMap;
|
|
51
60
|
}
|
|
52
61
|
|
package/src/emit.ts
CHANGED
|
@@ -20,13 +20,15 @@ import { toJsonSchema } from "@tetsujs/core";
|
|
|
20
20
|
const target = { target: "draft-2020-12" } as const;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* A JSON Schema as the generator reads it: any keyword, with the
|
|
23
|
+
* A JSON Schema as the generator reads it: any keyword, with the ones it
|
|
24
24
|
* looks into named, so reading them does not depend on the reader's
|
|
25
25
|
* `noPropertyAccessFromIndexSignature`.
|
|
26
26
|
*/
|
|
27
27
|
export interface JsonSchemaObject {
|
|
28
28
|
properties?: unknown;
|
|
29
29
|
required?: unknown;
|
|
30
|
+
anyOf?: unknown;
|
|
31
|
+
oneOf?: unknown;
|
|
30
32
|
[keyword: string]: unknown;
|
|
31
33
|
}
|
|
32
34
|
|
package/src/envelopes.ts
ADDED
|
@@ -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,10 +23,12 @@ 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 {
|
|
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,
|
|
@@ -41,12 +43,18 @@ export type {
|
|
|
41
43
|
ContentMap,
|
|
42
44
|
DocumentInfo,
|
|
43
45
|
DocumentServer,
|
|
46
|
+
HeaderObject,
|
|
44
47
|
OpenApiDocument,
|
|
45
48
|
OperationObject,
|
|
46
49
|
ParameterObject,
|
|
47
50
|
PathItemObject,
|
|
48
51
|
ResponseObject,
|
|
49
52
|
} from "./document.ts";
|
|
53
|
+
export type {
|
|
54
|
+
JsonSchema,
|
|
55
|
+
JsonSchemaKeywords,
|
|
56
|
+
JsonSchemaType,
|
|
57
|
+
} from "./json-schema.ts";
|
|
50
58
|
export type { DocsAssets, DocsPageOptions, DocsUi } from "./page.ts";
|
|
51
59
|
export { docsPage } from "./page.ts";
|
|
52
60
|
|
|
@@ -103,7 +111,15 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
103
111
|
const warnings: GeneratorWarning[] = [];
|
|
104
112
|
const schemes: Record<string, unknown> = {};
|
|
105
113
|
const components = schemaComponents();
|
|
114
|
+
const envelopes = envelopeDefinitions(components);
|
|
106
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
|
+
}
|
|
107
123
|
|
|
108
124
|
// Which route already occupies each slot of the document. Two routes the
|
|
109
125
|
// router keeps apart can still meet here, because a template says less
|
|
@@ -114,11 +130,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
114
130
|
// that works. It still loses it; it no longer does so without saying.
|
|
115
131
|
const occupied = new Map<string, string>();
|
|
116
132
|
|
|
117
|
-
for (const entry of
|
|
118
|
-
if (entry.ws || entry.def.docs?.hidden) {
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
133
|
+
for (const entry of described) {
|
|
122
134
|
const template = toTemplate(entry.path);
|
|
123
135
|
const route = `${entry.method} ${entry.path}`;
|
|
124
136
|
|
|
@@ -126,6 +138,7 @@ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
|
126
138
|
entry,
|
|
127
139
|
app.options,
|
|
128
140
|
components,
|
|
141
|
+
envelopes,
|
|
129
142
|
ids,
|
|
130
143
|
(message) => {
|
|
131
144
|
warnings.push({ route, message });
|