@tetsujs/openapi 0.1.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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/src/annotations.d.ts +141 -0
- package/dist/src/annotations.d.ts.map +1 -0
- package/dist/src/components.d.ts +45 -0
- package/dist/src/components.d.ts.map +1 -0
- package/dist/src/controller.d.ts +91 -0
- package/dist/src/controller.d.ts.map +1 -0
- package/dist/src/document.d.ts +80 -0
- package/dist/src/document.d.ts.map +1 -0
- package/dist/src/emit.d.ts +40 -0
- package/dist/src/emit.d.ts.map +1 -0
- package/dist/src/index.d.ts +75 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +616 -0
- package/dist/src/index.js.map +17 -0
- package/dist/src/operation.d.ts +39 -0
- package/dist/src/operation.d.ts.map +1 -0
- package/dist/src/page.d.ts +54 -0
- package/dist/src/page.d.ts.map +1 -0
- package/dist/src/parameters.d.ts +47 -0
- package/dist/src/parameters.d.ts.map +1 -0
- package/package.json +52 -0
- package/src/annotations.ts +225 -0
- package/src/components.ts +90 -0
- package/src/controller.ts +175 -0
- package/src/document.ts +90 -0
- package/src/emit.ts +98 -0
- package/src/index.ts +203 -0
- package/src/operation.ts +583 -0
- package/src/page.ts +139 -0
- package/src/parameters.ts +145 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document's `components/schemas` section.
|
|
3
|
+
*
|
|
4
|
+
* The failures the framework fills in repeat across every operation — a
|
|
5
|
+
* `500` on all of them, a `413` on every route with a body — and inlining
|
|
6
|
+
* the same envelope each time makes a document that is mostly boilerplate.
|
|
7
|
+
* They are defined once here and referenced with `$ref`, which is also how
|
|
8
|
+
* a client generator ends up with one `MalformedJson` type instead of one
|
|
9
|
+
* per endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Only the framework's own shapes go here. A schema that came from a
|
|
12
|
+
* validator is still inlined: it is the author's, it has no name the
|
|
13
|
+
* generator could give it, and `$ref` would invent one.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A reference to a definition in `components/schemas`. */
|
|
19
|
+
export interface SchemaRef {
|
|
20
|
+
readonly $ref: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The definitions collected while generating a document. */
|
|
24
|
+
export interface SchemaComponents {
|
|
25
|
+
/**
|
|
26
|
+
* Registers a schema and returns the reference to it.
|
|
27
|
+
*
|
|
28
|
+
* Identical schemas share one definition, whatever name each caller
|
|
29
|
+
* asked for; different schemas that want the same name get a numbered
|
|
30
|
+
* one, so a document is never silently wrong about what a `$ref` points
|
|
31
|
+
* at.
|
|
32
|
+
*/
|
|
33
|
+
ref(preferred: string, schema: Record<string, unknown>): SchemaRef;
|
|
34
|
+
|
|
35
|
+
/** What was registered, in registration order. */
|
|
36
|
+
readonly schemas: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function schemaComponents(): SchemaComponents {
|
|
40
|
+
const schemas: Record<string, unknown> = {};
|
|
41
|
+
const registered = new Map<string, string>();
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
schemas,
|
|
45
|
+
|
|
46
|
+
ref(preferred: string, schema: Record<string, unknown>): SchemaRef {
|
|
47
|
+
const fingerprint = JSON.stringify(schema);
|
|
48
|
+
const already = registered.get(fingerprint);
|
|
49
|
+
|
|
50
|
+
if (already) {
|
|
51
|
+
return { $ref: `#/components/schemas/${already}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let name = preferred;
|
|
55
|
+
|
|
56
|
+
for (let attempt = 2; name in schemas; attempt += 1) {
|
|
57
|
+
name = `${preferred}${attempt}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
schemas[name] = schema;
|
|
61
|
+
registered.set(fingerprint, name);
|
|
62
|
+
|
|
63
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The name a failure is defined under: its `error` code in PascalCase —
|
|
70
|
+
* `MALFORMED_JSON` becomes `MalformedJson` — so the definition and the code
|
|
71
|
+
* a client branches on read as the same thing.
|
|
72
|
+
*
|
|
73
|
+
* A failure whose code only the hook knows, and which the hook did not
|
|
74
|
+
* declare, is named after its status instead.
|
|
75
|
+
*/
|
|
76
|
+
export function failureName(status: number, error?: string): string {
|
|
77
|
+
const name = (error ?? "")
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.split(/[^a-z0-9]+/)
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
82
|
+
.join("");
|
|
83
|
+
|
|
84
|
+
// A code with nothing ASCII in it sanitizes to nothing — a code in
|
|
85
|
+
// Cyrillic or Japanese, or one that is all punctuation. That is the same
|
|
86
|
+
// situation as no code at all, and gets the same name: the alternative
|
|
87
|
+
// was a component keyed `""`, which the specification does not allow, and
|
|
88
|
+
// a second such code keyed `"2"` by the collision numbering below it.
|
|
89
|
+
return name || `Failure${status}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documentation endpoints as a mountable controller.
|
|
3
|
+
*
|
|
4
|
+
* `openapi()` returns data and mounts nothing, which is the right default
|
|
5
|
+
* for a generator but a chore in the ordinary case: every application ends
|
|
6
|
+
* up writing the same controller, holding the same application, to serve
|
|
7
|
+
* the same two routes. {@link docs} is that controller, written once.
|
|
8
|
+
*
|
|
9
|
+
* It is a controller like any other — mounted in `routes`, groupable,
|
|
10
|
+
* hookable — that receives the application through `onMount` and generates
|
|
11
|
+
* the document from it at startup.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { App, RouteDef, SchemaConfig, ValidatePath } from "@tetsujs/core";
|
|
17
|
+
import { onMount, route } from "@tetsujs/core";
|
|
18
|
+
import type { OpenApiDocument } from "./document.ts";
|
|
19
|
+
import type { GeneratorWarning } from "./index.ts";
|
|
20
|
+
import { openapi } from "./index.ts";
|
|
21
|
+
import type { DocsAssets, DocsUi } from "./page.ts";
|
|
22
|
+
import { docsPage } from "./page.ts";
|
|
23
|
+
|
|
24
|
+
/** What the documentation controller serves, and from where. */
|
|
25
|
+
export interface DocsOptions<Path extends string, UiPath extends string> {
|
|
26
|
+
/** Title, version and the rest of the document's `info` block. */
|
|
27
|
+
readonly info: OpenApiDocument["info"];
|
|
28
|
+
|
|
29
|
+
/** Servers the API is reachable at. */
|
|
30
|
+
readonly servers?: OpenApiDocument["servers"];
|
|
31
|
+
|
|
32
|
+
/** Where the document is served. Defaults to `/openapi.json`. */
|
|
33
|
+
readonly path?: Path & ValidatePath<Path>;
|
|
34
|
+
|
|
35
|
+
/** Where the page is served. Defaults to `/docs`. */
|
|
36
|
+
readonly uiPath?: UiPath & ValidatePath<UiPath>;
|
|
37
|
+
|
|
38
|
+
/** Which renderer the page bootstraps. Defaults to `"scalar"`. */
|
|
39
|
+
readonly ui?: DocsUi;
|
|
40
|
+
|
|
41
|
+
/** Browser title of the page. Defaults to the document's own title. */
|
|
42
|
+
readonly title?: string;
|
|
43
|
+
|
|
44
|
+
/** Overrides the renderer's CDN URLs, for a pinned or self-hosted copy. */
|
|
45
|
+
readonly assets?: DocsAssets;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Documents the two endpoints this controller serves.
|
|
49
|
+
*
|
|
50
|
+
* Off by default: a reader who is looking at the document has already
|
|
51
|
+
* found the document, and an operation for "the page you are reading"
|
|
52
|
+
* is one a generated client would happily produce a method for.
|
|
53
|
+
*/
|
|
54
|
+
readonly documentSelf?: boolean;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What to do with what the generator could not describe.
|
|
58
|
+
*
|
|
59
|
+
* Defaults to one `console.warn` per warning, at startup — a document
|
|
60
|
+
* that silently omits a body is worse than a noisy one.
|
|
61
|
+
*/
|
|
62
|
+
readonly onWarning?: (warning: GeneratorWarning) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The controller {@link docs} returns: two routes and the `onMount` that
|
|
67
|
+
* fills them.
|
|
68
|
+
*
|
|
69
|
+
* Instantiated through the factory, never directly — the routes carry the
|
|
70
|
+
* configured paths in their types, which only the factory can infer.
|
|
71
|
+
*/
|
|
72
|
+
export class DocsController<Path extends string, UiPath extends string> {
|
|
73
|
+
/** The document itself, as `application/json`. */
|
|
74
|
+
readonly json: RouteDef<Path, SchemaConfig, never, undefined, "GET">;
|
|
75
|
+
|
|
76
|
+
/** The page that renders it. */
|
|
77
|
+
readonly ui: RouteDef<UiPath, SchemaConfig, never, undefined, "GET">;
|
|
78
|
+
|
|
79
|
+
private document: OpenApiDocument | undefined;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly options: DocsOptions<Path, UiPath>,
|
|
83
|
+
documentPath: string,
|
|
84
|
+
uiPath: string,
|
|
85
|
+
page: string,
|
|
86
|
+
) {
|
|
87
|
+
const hidden = !options.documentSelf;
|
|
88
|
+
|
|
89
|
+
const json = route({
|
|
90
|
+
method: "GET",
|
|
91
|
+
path: "/openapi.json",
|
|
92
|
+
docs: { summary: "This document", tags: ["docs"], hidden },
|
|
93
|
+
handler: () => this.document,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const ui = route({
|
|
97
|
+
method: "GET",
|
|
98
|
+
path: "/docs",
|
|
99
|
+
docs: { summary: "Rendered documentation", tags: ["docs"], hidden },
|
|
100
|
+
handler: () =>
|
|
101
|
+
new Response(page, {
|
|
102
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The configured paths, put back on the routes.
|
|
108
|
+
*
|
|
109
|
+
* `route()` validates its path at the type level, and that check
|
|
110
|
+
* cannot be satisfied by a type variable — `ValidatePath<Path>` stays
|
|
111
|
+
* unresolved while `Path` is generic. The routes are therefore built
|
|
112
|
+
* with the default literals and re-typed here, which is also where the
|
|
113
|
+
* configured path lands at runtime.
|
|
114
|
+
*/
|
|
115
|
+
this.json = { ...json, path: documentPath } as unknown as this["json"];
|
|
116
|
+
this.ui = { ...ui, path: uiPath } as unknown as this["ui"];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Generates the document of the application this was mounted in. */
|
|
120
|
+
[onMount](app: App): void {
|
|
121
|
+
const generated = openapi(app, {
|
|
122
|
+
info: this.options.info,
|
|
123
|
+
...(this.options.servers ? { servers: this.options.servers } : {}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.document = generated.document;
|
|
127
|
+
|
|
128
|
+
const report = this.options.onWarning ?? warn;
|
|
129
|
+
|
|
130
|
+
for (const warning of generated.warnings) {
|
|
131
|
+
report(warning);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The documentation of an application, as a controller to mount.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* createApp({
|
|
142
|
+
* routes: [
|
|
143
|
+
* group("/api", { children: [new UsersController(users)] }),
|
|
144
|
+
* docs({ info: { title: "Users API", version: "1.0.0" } }),
|
|
145
|
+
* ],
|
|
146
|
+
* });
|
|
147
|
+
* ```
|
|
148
|
+
*
|
|
149
|
+
* The document is generated once, at startup, from the application the
|
|
150
|
+
* controller was mounted in. Its own two routes stay out of it — pass
|
|
151
|
+
* `documentSelf` to put them in.
|
|
152
|
+
*
|
|
153
|
+
* The page loads its renderer from a CDN; see {@link docsPage} for the
|
|
154
|
+
* trade and for how to point it at a self-hosted copy.
|
|
155
|
+
*/
|
|
156
|
+
export function docs<
|
|
157
|
+
const Path extends string = "/openapi.json",
|
|
158
|
+
const UiPath extends string = "/docs",
|
|
159
|
+
>(options: DocsOptions<Path, UiPath>): DocsController<Path, UiPath> {
|
|
160
|
+
const documentPath = options.path ?? "/openapi.json";
|
|
161
|
+
const uiPath = options.uiPath ?? "/docs";
|
|
162
|
+
|
|
163
|
+
const page = docsPage({
|
|
164
|
+
ui: options.ui ?? "scalar",
|
|
165
|
+
documentUrl: documentPath,
|
|
166
|
+
title: options.title ?? options.info.title,
|
|
167
|
+
...(options.assets ? { assets: options.assets } : {}),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return new DocsController(options, documentPath, uiPath, page);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function warn(warning: GeneratorWarning): void {
|
|
174
|
+
console.warn(`[openapi] ${warning.route}: ${warning.message}`);
|
|
175
|
+
}
|
package/src/document.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of the documents this package produces.
|
|
3
|
+
*
|
|
4
|
+
* Typed only as far as the generator fills it in: an OpenAPI document is
|
|
5
|
+
* far larger than what a route table can honestly describe, and declaring
|
|
6
|
+
* the rest would promise support that does not exist. Everything typed
|
|
7
|
+
* here is emitted; everything a caller adds by hand passes through the
|
|
8
|
+
* index signatures.
|
|
9
|
+
*
|
|
10
|
+
* OpenAPI 3.1 only. It is JSON Schema 2020-12 verbatim, which is what
|
|
11
|
+
* validators emit through Standard Schema — 3.0 would mean converting the
|
|
12
|
+
* dialect and being subtly wrong about `nullable`, `examples` and binary
|
|
13
|
+
* payloads.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Metadata every OpenAPI document must carry. */
|
|
19
|
+
export interface DocumentInfo {
|
|
20
|
+
readonly title: string;
|
|
21
|
+
readonly version: string;
|
|
22
|
+
readonly description?: string;
|
|
23
|
+
readonly [key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A server the API is reachable at. */
|
|
27
|
+
export interface DocumentServer {
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly description?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One request parameter: a path segment, a query key or a header. */
|
|
33
|
+
export interface ParameterObject {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly in: "path" | "query" | "header" | "cookie";
|
|
36
|
+
readonly required: boolean;
|
|
37
|
+
readonly description?: string;
|
|
38
|
+
readonly schema?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A body or response payload, keyed by media type. */
|
|
42
|
+
export type ContentMap = Record<
|
|
43
|
+
string,
|
|
44
|
+
{ readonly schema?: Record<string, unknown> }
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
/** One response of an operation. */
|
|
48
|
+
export interface ResponseObject {
|
|
49
|
+
readonly description: string;
|
|
50
|
+
readonly content?: ContentMap;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One endpoint: a method on a path. */
|
|
54
|
+
export interface OperationObject {
|
|
55
|
+
readonly operationId: string;
|
|
56
|
+
readonly summary?: string;
|
|
57
|
+
readonly description?: string;
|
|
58
|
+
readonly tags?: readonly string[];
|
|
59
|
+
readonly deprecated?: boolean;
|
|
60
|
+
readonly parameters?: readonly ParameterObject[];
|
|
61
|
+
readonly requestBody?: {
|
|
62
|
+
readonly required: boolean;
|
|
63
|
+
readonly content: ContentMap;
|
|
64
|
+
};
|
|
65
|
+
readonly security?: readonly Record<string, readonly string[]>[];
|
|
66
|
+
readonly responses: Record<string, ResponseObject>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every operation declared on one path template. */
|
|
70
|
+
export type PathItemObject = Record<string, OperationObject>;
|
|
71
|
+
|
|
72
|
+
/** The generated document. */
|
|
73
|
+
export interface OpenApiDocument {
|
|
74
|
+
readonly openapi: "3.1.0";
|
|
75
|
+
readonly info: DocumentInfo;
|
|
76
|
+
readonly servers?: readonly DocumentServer[];
|
|
77
|
+
readonly paths: Record<string, PathItemObject>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the operations refer to rather than repeat: the security schemes
|
|
81
|
+
* the guards registered, and the failure envelopes the framework fills
|
|
82
|
+
* in. Absent when neither has anything to say.
|
|
83
|
+
*/
|
|
84
|
+
readonly components?: {
|
|
85
|
+
readonly securitySchemes?: Record<string, unknown>;
|
|
86
|
+
readonly schemas?: Record<string, unknown>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
readonly [key: string]: unknown;
|
|
90
|
+
}
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a schema to describe itself, without letting it stop the document.
|
|
3
|
+
*
|
|
4
|
+
* Internal to the package.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { toJsonSchema } from "@tetsujs/core";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The JSON Schema dialect a schema is asked for.
|
|
13
|
+
*
|
|
14
|
+
* OpenAPI 3.1 takes JSON Schema 2020-12 as it is, and `draft-2020-12` is a
|
|
15
|
+
* target the Standard JSON Schema specification names — so every library
|
|
16
|
+
* that implements it answers. This used to be `openapi-3.1`, which only
|
|
17
|
+
* Zod accepts: ArkType and Valibot refused it, and their bodies went into
|
|
18
|
+
* the document without a shape and with a warning each.
|
|
19
|
+
*/
|
|
20
|
+
const target = { target: "draft-2020-12" } as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A JSON Schema as the generator reads it: any keyword, with the two it
|
|
24
|
+
* looks into named, so reading them does not depend on the reader's
|
|
25
|
+
* `noPropertyAccessFromIndexSignature`.
|
|
26
|
+
*/
|
|
27
|
+
export interface JsonSchemaObject {
|
|
28
|
+
properties?: unknown;
|
|
29
|
+
required?: unknown;
|
|
30
|
+
[keyword: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Which shape of a schema to describe: what arrives, or what leaves. */
|
|
34
|
+
type Direction = "input" | "output";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The JSON Schema of a declaration, or `undefined` and a warning saying why.
|
|
38
|
+
*
|
|
39
|
+
* A schema can fail to describe itself in two ways, and the generator owes
|
|
40
|
+
* the same answer to both: an adapter without JSON Schema support returns
|
|
41
|
+
* nothing, and an adapter that has it can still throw — a converter meeting
|
|
42
|
+
* a shape it cannot express, a structure too deep to walk, a `$ref` that
|
|
43
|
+
* leads back to where it started.
|
|
44
|
+
*
|
|
45
|
+
* Only the first was handled, and the difference between them is not the
|
|
46
|
+
* document: it is whether the application starts at all. `docs()` builds
|
|
47
|
+
* the document from `onMount`, which `createApp` calls while it is still
|
|
48
|
+
* assembling itself, so an escaping error is not a gap in a description —
|
|
49
|
+
* it is a server that never listens, over a route that would have worked.
|
|
50
|
+
*
|
|
51
|
+
* `subject` names the declaration for the message, as the reader of a
|
|
52
|
+
* warning would name it: `"the body"`, `"a response"`.
|
|
53
|
+
*/
|
|
54
|
+
export function emitted(
|
|
55
|
+
schema: unknown,
|
|
56
|
+
subject: string,
|
|
57
|
+
warn: (message: string) => void,
|
|
58
|
+
direction: Direction = "output",
|
|
59
|
+
): JsonSchemaObject | undefined {
|
|
60
|
+
let described: JsonSchemaObject | undefined;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
described = toJsonSchema(schema, target, direction);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
warn(`${subject} schema failed to emit JSON Schema: ${reason(error)}`);
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!described) {
|
|
71
|
+
warn(`${subject} schema does not emit JSON Schema`);
|
|
72
|
+
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return withoutDialect(described);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A schema as the document embeds it: without its own `$schema`.
|
|
81
|
+
*
|
|
82
|
+
* Asked for draft 2020-12, Zod and ArkType name the dialect at the root of
|
|
83
|
+
* every schema they emit. The document declares it once for all of them,
|
|
84
|
+
* and the same line on every body and response is noise.
|
|
85
|
+
*/
|
|
86
|
+
function withoutDialect(schema: JsonSchemaObject): JsonSchemaObject {
|
|
87
|
+
if (!("$schema" in schema)) {
|
|
88
|
+
return schema;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { $schema: _, ...rest } = schema;
|
|
92
|
+
|
|
93
|
+
return rest;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function reason(error: unknown): string {
|
|
97
|
+
return error instanceof Error ? error.message : String(error);
|
|
98
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI generation for the framework.
|
|
3
|
+
*
|
|
4
|
+
* Reads a compiled application — `app.entries` and the options it actually
|
|
5
|
+
* runs with — and returns an OpenAPI 3.1 document. Nothing is registered,
|
|
6
|
+
* served or mutated: the document is data, and serving it is a route the
|
|
7
|
+
* application declares like any other.
|
|
8
|
+
*
|
|
9
|
+
* Serving it is a route the application declares — or `docs()`, the
|
|
10
|
+
* controller that declares those routes for you.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const { document, warnings } = openapi(app, {
|
|
15
|
+
* info: { title: "Users API", version: "1.0.0" },
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { App } from "@tetsujs/core";
|
|
23
|
+
import { contributionsOf } from "./annotations.ts";
|
|
24
|
+
import { schemaComponents } from "./components.ts";
|
|
25
|
+
import type { OpenApiDocument, PathItemObject } from "./document.ts";
|
|
26
|
+
import { operationIds, operationOf } from "./operation.ts";
|
|
27
|
+
import { toTemplate } from "./parameters.ts";
|
|
28
|
+
|
|
29
|
+
export type {
|
|
30
|
+
DocumentedResponse,
|
|
31
|
+
HookContributions,
|
|
32
|
+
HookDocs,
|
|
33
|
+
SecurityRequirement,
|
|
34
|
+
SecurityScheme,
|
|
35
|
+
} from "./annotations.ts";
|
|
36
|
+
export { documented, secured } from "./annotations.ts";
|
|
37
|
+
export type { DocsOptions } from "./controller.ts";
|
|
38
|
+
export { DocsController, docs } from "./controller.ts";
|
|
39
|
+
export type {
|
|
40
|
+
ContentMap,
|
|
41
|
+
DocumentInfo,
|
|
42
|
+
DocumentServer,
|
|
43
|
+
OpenApiDocument,
|
|
44
|
+
OperationObject,
|
|
45
|
+
ParameterObject,
|
|
46
|
+
PathItemObject,
|
|
47
|
+
ResponseObject,
|
|
48
|
+
} from "./document.ts";
|
|
49
|
+
export type { DocsAssets, DocsPageOptions, DocsUi } from "./page.ts";
|
|
50
|
+
export { docsPage } from "./page.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the generator cannot read off the application.
|
|
54
|
+
*/
|
|
55
|
+
export interface OpenApiOptions {
|
|
56
|
+
/** Title, version and the rest of the document's `info` block. */
|
|
57
|
+
readonly info: OpenApiDocument["info"];
|
|
58
|
+
|
|
59
|
+
/** Servers the API is reachable at. */
|
|
60
|
+
readonly servers?: OpenApiDocument["servers"];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What the generator could not describe faithfully.
|
|
65
|
+
*
|
|
66
|
+
* Each entry names a route and what was missing — almost always a schema
|
|
67
|
+
* from a validator that emits no JSON Schema. Documentation that silently
|
|
68
|
+
* omits a body is worse than documentation that says it does not know, so
|
|
69
|
+
* these are returned rather than swallowed.
|
|
70
|
+
*/
|
|
71
|
+
export interface GeneratorWarning {
|
|
72
|
+
readonly route: string;
|
|
73
|
+
readonly message: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A generated document together with what could not be described. */
|
|
77
|
+
export interface GeneratorResult {
|
|
78
|
+
readonly document: OpenApiDocument;
|
|
79
|
+
readonly warnings: readonly GeneratorWarning[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generates the OpenAPI document of an application.
|
|
84
|
+
*
|
|
85
|
+
* Socket endpoints are skipped: a handshake is a `GET` in the table, but
|
|
86
|
+
* OpenAPI has no way to describe what happens after it, and documenting
|
|
87
|
+
* the upgrade as an ordinary operation would describe a request that
|
|
88
|
+
* clients must not make. AsyncAPI is that format, and a separate document.
|
|
89
|
+
*
|
|
90
|
+
* So are routes marked `docs: { hidden: true }`, and everything they would
|
|
91
|
+
* have contributed with them: a security scheme only a hidden route
|
|
92
|
+
* requires has nothing left to describe.
|
|
93
|
+
*
|
|
94
|
+
* Every route of the table becomes an operation under its path template,
|
|
95
|
+
* with the parameters, body and responses its schemas describe. What the
|
|
96
|
+
* framework itself can answer — a validation failure, an unparsable body,
|
|
97
|
+
* a body over the limit — is filled in from the options the application
|
|
98
|
+
* runs with, so the document states the status actually configured.
|
|
99
|
+
*/
|
|
100
|
+
export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
|
|
101
|
+
const paths: Record<string, PathItemObject> = {};
|
|
102
|
+
const warnings: GeneratorWarning[] = [];
|
|
103
|
+
const schemes: Record<string, unknown> = {};
|
|
104
|
+
const components = schemaComponents();
|
|
105
|
+
const ids = operationIds();
|
|
106
|
+
|
|
107
|
+
// Which route already occupies each slot of the document. Two routes the
|
|
108
|
+
// router keeps apart can still meet here, because a template says less
|
|
109
|
+
// than a path does: `/files/*` and `/files/:wildcard` both become
|
|
110
|
+
// `/files/{wildcard}`, and a literal `/users/{id}` — which Bun matches as
|
|
111
|
+
// the string it is — is spelled the same as `/users/:id`. The second one
|
|
112
|
+
// then replaces the first, and the document quietly loses an endpoint
|
|
113
|
+
// that works. It still loses it; it no longer does so without saying.
|
|
114
|
+
const occupied = new Map<string, string>();
|
|
115
|
+
|
|
116
|
+
for (const entry of app.entries) {
|
|
117
|
+
if (entry.ws || entry.def.docs?.hidden) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const template = toTemplate(entry.path);
|
|
122
|
+
const route = `${entry.method} ${entry.path}`;
|
|
123
|
+
|
|
124
|
+
const operation = operationOf(
|
|
125
|
+
entry,
|
|
126
|
+
app.options,
|
|
127
|
+
components,
|
|
128
|
+
ids,
|
|
129
|
+
(message) => {
|
|
130
|
+
warnings.push({ route, message });
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// First definition wins, as it does among the hooks of one route: a
|
|
135
|
+
// name is the identity of a scheme, and the operations that reference
|
|
136
|
+
// it say only the name. A second, different scheme under that name
|
|
137
|
+
// cannot be described without renaming it out from under everything
|
|
138
|
+
// already pointing at it, so it is refused rather than silently
|
|
139
|
+
// swapped in — which used to make every earlier operation describe an
|
|
140
|
+
// authentication its route does not perform.
|
|
141
|
+
for (const requirement of contributionsOf(entry.hooks).security) {
|
|
142
|
+
const defined = schemes[requirement.name];
|
|
143
|
+
|
|
144
|
+
if (defined === undefined) {
|
|
145
|
+
schemes[requirement.name] = requirement.scheme;
|
|
146
|
+
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (JSON.stringify(defined) !== JSON.stringify(requirement.scheme)) {
|
|
151
|
+
warnings.push({
|
|
152
|
+
route,
|
|
153
|
+
message: `the security scheme "${requirement.name}" is already defined differently; this definition is ignored`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const slot = `${entry.method.toLowerCase()} ${template}`;
|
|
159
|
+
const taken = occupied.get(slot);
|
|
160
|
+
|
|
161
|
+
if (taken) {
|
|
162
|
+
warnings.push({
|
|
163
|
+
route,
|
|
164
|
+
message: `${template} is already described by ${taken}, whose operation this replaces`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
occupied.set(slot, route);
|
|
169
|
+
|
|
170
|
+
paths[template] = {
|
|
171
|
+
...paths[template],
|
|
172
|
+
[entry.method.toLowerCase()]: operation,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
document: {
|
|
178
|
+
openapi: "3.1.0",
|
|
179
|
+
info: options.info,
|
|
180
|
+
...(options.servers ? { servers: options.servers } : {}),
|
|
181
|
+
paths,
|
|
182
|
+
...componentsOf(schemes, components.schemas),
|
|
183
|
+
},
|
|
184
|
+
warnings,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The `components` block, present only when something goes in it: the
|
|
190
|
+
* security schemes the guards registered, and the failure envelopes the
|
|
191
|
+
* operations refer to.
|
|
192
|
+
*/
|
|
193
|
+
function componentsOf(
|
|
194
|
+
securitySchemes: Record<string, unknown>,
|
|
195
|
+
schemas: Record<string, unknown>,
|
|
196
|
+
): { components?: Record<string, unknown> } {
|
|
197
|
+
const components = {
|
|
198
|
+
...(Object.keys(securitySchemes).length > 0 ? { securitySchemes } : {}),
|
|
199
|
+
...(Object.keys(schemas).length > 0 ? { schemas } : {}),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return Object.keys(components).length > 0 ? { components } : {};
|
|
203
|
+
}
|