@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/README.md +82 -3
- package/dist/src/annotations.d.ts +93 -10
- 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 +360 -122
- 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 +162 -21
- 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 +20 -6
- package/src/json-schema.ts +108 -0
- package/src/operation.ts +381 -152
- package/src/page.ts +65 -6
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,16 +17,17 @@
|
|
|
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.
|
|
23
24
|
*
|
|
24
|
-
* Typed as loosely as the spec is wide — `http`, `apiKey`, `oauth2
|
|
25
|
-
* `openIdConnect` carry different fields, and narrowing
|
|
26
|
-
* be a second, poorer copy of the specification.
|
|
25
|
+
* Typed as loosely as the spec is wide — `http`, `apiKey`, `oauth2`,
|
|
26
|
+
* `openIdConnect` and `mutualTLS` carry different fields, and narrowing
|
|
27
|
+
* them here would be a second, poorer copy of the specification.
|
|
27
28
|
*/
|
|
28
29
|
export interface SecurityScheme {
|
|
29
|
-
readonly type: "http" | "apiKey" | "oauth2" | "openIdConnect";
|
|
30
|
+
readonly type: "http" | "apiKey" | "oauth2" | "openIdConnect" | "mutualTLS";
|
|
30
31
|
readonly [key: string]: unknown;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -83,12 +84,70 @@ 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;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Requirements of which a hook accepts any one: a session cookie *or* a
|
|
133
|
+
* bearer token, checked by one hook.
|
|
134
|
+
*
|
|
135
|
+
* "Either" lives inside a hook, never between hooks — every hook of a
|
|
136
|
+
* route runs, so the schemes of two hooks are both required. A hook that
|
|
137
|
+
* takes either of two credentials says so here, and the document lists
|
|
138
|
+
* the combinations a client may bring.
|
|
139
|
+
*/
|
|
140
|
+
export interface SecurityAlternatives {
|
|
141
|
+
readonly anyOf: readonly SecurityRequirement[];
|
|
86
142
|
}
|
|
87
143
|
|
|
88
144
|
/** Everything a hook tells the generator about itself. */
|
|
89
145
|
export interface HookDocs {
|
|
90
|
-
/**
|
|
91
|
-
|
|
146
|
+
/**
|
|
147
|
+
* What the hook demands of a request: one requirement, or alternatives
|
|
148
|
+
* of which any one will do.
|
|
149
|
+
*/
|
|
150
|
+
readonly security?: SecurityRequirement | SecurityAlternatives;
|
|
92
151
|
|
|
93
152
|
/** What the hook can answer with. */
|
|
94
153
|
readonly responses?: readonly DocumentedResponse[];
|
|
@@ -128,6 +187,10 @@ export function documented<H extends AnyHook>(hook: H, docs: HookDocs): H {
|
|
|
128
187
|
* Annotates a hook with the security it enforces — {@link documented} for
|
|
129
188
|
* the case that comes up most.
|
|
130
189
|
*
|
|
190
|
+
* One requirement, or `{ anyOf: [...] }` for a hook that accepts any one
|
|
191
|
+
* of several: the schemes of different hooks are all required, the
|
|
192
|
+
* alternatives of one hook are not.
|
|
193
|
+
*
|
|
131
194
|
* @example
|
|
132
195
|
* ```ts
|
|
133
196
|
* export const auth = secured(
|
|
@@ -142,10 +205,22 @@ export function documented<H extends AnyHook>(hook: H, docs: HookDocs): H {
|
|
|
142
205
|
* },
|
|
143
206
|
* );
|
|
144
207
|
* ```
|
|
208
|
+
*
|
|
209
|
+
* @example A hook that accepts a session cookie or a bearer token
|
|
210
|
+
* ```ts
|
|
211
|
+
* export const caller = secured(
|
|
212
|
+
* hook.beforeParse((ctx) => {
|
|
213
|
+
* const user = sessions.fromCookie(ctx) ?? tokens.fromHeader(ctx);
|
|
214
|
+
* if (!user) throw new HttpError(401);
|
|
215
|
+
* return { user };
|
|
216
|
+
* }),
|
|
217
|
+
* { anyOf: [cookieSession, bearerToken] },
|
|
218
|
+
* );
|
|
219
|
+
* ```
|
|
145
220
|
*/
|
|
146
221
|
export function secured<H extends AnyHook>(
|
|
147
222
|
hook: H,
|
|
148
|
-
requirement: SecurityRequirement,
|
|
223
|
+
requirement: SecurityRequirement | SecurityAlternatives,
|
|
149
224
|
): H {
|
|
150
225
|
return documented(hook, { security: requirement });
|
|
151
226
|
}
|
|
@@ -155,14 +230,28 @@ export function docsOf(hook: AnyHook): HookDocs | undefined {
|
|
|
155
230
|
return (hook as unknown as Record<string, HookDocs | undefined>)[docsKey];
|
|
156
231
|
}
|
|
157
232
|
|
|
158
|
-
/** Reads the security
|
|
159
|
-
export function securityOf(
|
|
233
|
+
/** Reads the security a hook was annotated with, if it was. */
|
|
234
|
+
export function securityOf(
|
|
235
|
+
hook: AnyHook,
|
|
236
|
+
): SecurityRequirement | SecurityAlternatives | undefined {
|
|
160
237
|
return docsOf(hook)?.security;
|
|
161
238
|
}
|
|
162
239
|
|
|
163
240
|
/** What the hooks of one route contribute, in execution order. */
|
|
164
241
|
export interface HookContributions {
|
|
242
|
+
/**
|
|
243
|
+
* Every scheme the hooks mention, once per name, with the scopes of all
|
|
244
|
+
* of them: what the document registers, and what a refusal answers with.
|
|
245
|
+
*/
|
|
165
246
|
readonly security: readonly SecurityRequirement[];
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What a request must satisfy, one condition per hook: every condition
|
|
250
|
+
* is required, and any one requirement of a condition satisfies it. A
|
|
251
|
+
* hook with one requirement is a condition of one.
|
|
252
|
+
*/
|
|
253
|
+
readonly conditions: readonly (readonly SecurityRequirement[])[];
|
|
254
|
+
|
|
166
255
|
readonly responses: readonly DocumentedResponse[];
|
|
167
256
|
}
|
|
168
257
|
|
|
@@ -171,7 +260,9 @@ export interface HookContributions {
|
|
|
171
260
|
*
|
|
172
261
|
* A scheme is registered once per name and a response once per status and
|
|
173
262
|
* description: the same guard on the application and on a group is one
|
|
174
|
-
* requirement, not two.
|
|
263
|
+
* requirement, not two. Two hooks of one scheme asking for different
|
|
264
|
+
* scopes both run, so the requirement asks for the scopes of both — each
|
|
265
|
+
* once, in the order they were first asked for.
|
|
175
266
|
*
|
|
176
267
|
* Deduplicating by name means two hooks claiming one name with different
|
|
177
268
|
* schemes lose one of them, and that is not a duplicate being collapsed —
|
|
@@ -183,6 +274,7 @@ export function contributionsOf(
|
|
|
183
274
|
warn?: (message: string) => void,
|
|
184
275
|
): HookContributions {
|
|
185
276
|
const security = new Map<string, SecurityRequirement>();
|
|
277
|
+
const conditions = new Map<string, readonly SecurityRequirement[]>();
|
|
186
278
|
const responses = new Map<string, DocumentedResponse>();
|
|
187
279
|
|
|
188
280
|
for (const slot of Object.values(chains)) {
|
|
@@ -194,17 +286,28 @@ export function contributionsOf(
|
|
|
194
286
|
}
|
|
195
287
|
|
|
196
288
|
if (docs.security) {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
289
|
+
const alternatives = alternativesOf(docs.security);
|
|
290
|
+
|
|
291
|
+
conditions.set(conditionKey(alternatives), alternatives);
|
|
292
|
+
|
|
293
|
+
for (const requirement of alternatives) {
|
|
294
|
+
const claimed = security.get(requirement.name);
|
|
295
|
+
|
|
296
|
+
if (!claimed) {
|
|
297
|
+
security.set(requirement.name, requirement);
|
|
298
|
+
} else if (
|
|
299
|
+
JSON.stringify(claimed.scheme) ===
|
|
300
|
+
JSON.stringify(requirement.scheme)
|
|
301
|
+
) {
|
|
302
|
+
security.set(requirement.name, {
|
|
303
|
+
...claimed,
|
|
304
|
+
scopes: joinScopes(claimed.scopes, requirement.scopes),
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
warn?.(
|
|
308
|
+
`two hooks claim the security scheme "${requirement.name}" with different definitions; one of them is not described`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
208
311
|
}
|
|
209
312
|
}
|
|
210
313
|
|
|
@@ -220,6 +323,44 @@ export function contributionsOf(
|
|
|
220
323
|
|
|
221
324
|
return {
|
|
222
325
|
security: [...security.values()],
|
|
326
|
+
conditions: [...conditions.values()],
|
|
223
327
|
responses: [...responses.values()],
|
|
224
328
|
};
|
|
225
329
|
}
|
|
330
|
+
|
|
331
|
+
/** A hook's security as the alternatives it accepts; one is one. */
|
|
332
|
+
function alternativesOf(
|
|
333
|
+
security: SecurityRequirement | SecurityAlternatives,
|
|
334
|
+
): readonly SecurityRequirement[] {
|
|
335
|
+
return "anyOf" in security ? security.anyOf : [security];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* What makes two conditions the same one: the same guard mounted on the
|
|
340
|
+
* application and on a group is one condition, and counting it twice would
|
|
341
|
+
* multiply its alternatives with themselves.
|
|
342
|
+
*/
|
|
343
|
+
function conditionKey(alternatives: readonly SecurityRequirement[]): string {
|
|
344
|
+
return JSON.stringify(
|
|
345
|
+
alternatives.map((requirement) => [
|
|
346
|
+
requirement.name,
|
|
347
|
+
[...(requirement.scopes ?? [])].sort(),
|
|
348
|
+
]),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The scopes of two requirements of one scheme, each once, in order. */
|
|
353
|
+
function joinScopes(
|
|
354
|
+
first: readonly string[] | undefined,
|
|
355
|
+
second: readonly string[] | undefined,
|
|
356
|
+
): readonly string[] {
|
|
357
|
+
const joined = [...(first ?? [])];
|
|
358
|
+
|
|
359
|
+
for (const scope of second ?? []) {
|
|
360
|
+
if (!joined.includes(scope)) {
|
|
361
|
+
joined.push(scope);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return joined;
|
|
366
|
+
}
|
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
|
|