@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
package/src/page.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTML page that renders a document.
|
|
3
|
+
*
|
|
4
|
+
* Returns a string and mounts nothing: which path serves the page, and
|
|
5
|
+
* whether one is served at all, is the application's decision like every
|
|
6
|
+
* other route.
|
|
7
|
+
*
|
|
8
|
+
* The renderers themselves are loaded from a CDN. Vendoring Scalar or
|
|
9
|
+
* Swagger UI would put megabytes of somebody else's bundle into a
|
|
10
|
+
* dependency-free repository, so the trade is stated instead of hidden:
|
|
11
|
+
* the documentation page — and only it — needs the network, and the URL is
|
|
12
|
+
* a parameter, so a self-hosted copy is one option away. Nothing is
|
|
13
|
+
* fetched unless this function is called.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The renderers this package knows how to bootstrap. */
|
|
19
|
+
export type DocsUi = "scalar" | "swagger-ui" | "redoc";
|
|
20
|
+
|
|
21
|
+
/** Where a renderer is loaded from. */
|
|
22
|
+
export interface DocsAssets {
|
|
23
|
+
readonly script: string;
|
|
24
|
+
readonly style?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the page needs to know. */
|
|
28
|
+
export interface DocsPageOptions {
|
|
29
|
+
/** Which renderer to bootstrap. */
|
|
30
|
+
readonly ui: DocsUi;
|
|
31
|
+
|
|
32
|
+
/** Where the document is served — the page fetches it from there. */
|
|
33
|
+
readonly documentUrl: string;
|
|
34
|
+
|
|
35
|
+
/** Browser title; defaults to `"API documentation"`. */
|
|
36
|
+
readonly title?: string;
|
|
37
|
+
|
|
38
|
+
/** Overrides the CDN URLs, for a pinned version or a self-hosted copy. */
|
|
39
|
+
readonly assets?: DocsAssets;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const defaults: Record<DocsUi, DocsAssets> = {
|
|
43
|
+
scalar: {
|
|
44
|
+
script: "https://cdn.jsdelivr.net/npm/@scalar/api-reference",
|
|
45
|
+
},
|
|
46
|
+
"swagger-ui": {
|
|
47
|
+
script:
|
|
48
|
+
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js",
|
|
49
|
+
style: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css",
|
|
50
|
+
},
|
|
51
|
+
redoc: {
|
|
52
|
+
script: "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds the documentation page.
|
|
58
|
+
*
|
|
59
|
+
* Called by `docs()` for the page it serves; call it directly to serve one
|
|
60
|
+
* from somewhere else.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const page = docsPage({ ui: "scalar", documentUrl: "/openapi.json" });
|
|
65
|
+
*
|
|
66
|
+
* const ui = route({
|
|
67
|
+
* method: "GET",
|
|
68
|
+
* path: "/docs",
|
|
69
|
+
* handler: () =>
|
|
70
|
+
* new Response(page, { headers: { "content-type": "text/html" } }),
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function docsPage(options: DocsPageOptions): string {
|
|
75
|
+
const assets = options.assets ?? defaults[options.ui];
|
|
76
|
+
const title = escapeHtml(options.title ?? "API documentation");
|
|
77
|
+
const url = escapeHtml(options.documentUrl);
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
"<!doctype html>",
|
|
81
|
+
'<html lang="en">',
|
|
82
|
+
"<head>",
|
|
83
|
+
'<meta charset="utf-8" />',
|
|
84
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
85
|
+
`<title>${title}</title>`,
|
|
86
|
+
assets.style
|
|
87
|
+
? `<link rel="stylesheet" href="${escapeHtml(assets.style)}" />`
|
|
88
|
+
: "",
|
|
89
|
+
"</head>",
|
|
90
|
+
"<body>",
|
|
91
|
+
body(options.ui, url),
|
|
92
|
+
`<script src="${escapeHtml(assets.script)}"></script>`,
|
|
93
|
+
bootstrap(options.ui, url),
|
|
94
|
+
"</body>",
|
|
95
|
+
"</html>",
|
|
96
|
+
]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function body(ui: DocsUi, url: string): string {
|
|
102
|
+
if (ui === "scalar") {
|
|
103
|
+
return `<script id="api-reference" data-url="${url}"></script>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (ui === "redoc") {
|
|
107
|
+
return `<redoc spec-url="${url}"></redoc>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return '<div id="swagger-ui"></div>';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function bootstrap(ui: DocsUi, url: string): string {
|
|
114
|
+
if (ui !== "swagger-ui") {
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
"<script>",
|
|
120
|
+
`SwaggerUIBundle({ url: "${url}", dom_id: "#swagger-ui" });`,
|
|
121
|
+
"</script>",
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Escapes the values that reach the markup.
|
|
127
|
+
*
|
|
128
|
+
* The document URL and the title come from the application, but they are
|
|
129
|
+
* still values interpolated into HTML — a quote in either would break out
|
|
130
|
+
* of the attribute it sits in.
|
|
131
|
+
*/
|
|
132
|
+
function escapeHtml(value: string): string {
|
|
133
|
+
return value
|
|
134
|
+
.replaceAll("&", "&")
|
|
135
|
+
.replaceAll("<", "<")
|
|
136
|
+
.replaceAll(">", ">")
|
|
137
|
+
.replaceAll('"', """)
|
|
138
|
+
.replaceAll("'", "'");
|
|
139
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path templates and request parameters.
|
|
3
|
+
*
|
|
4
|
+
* The framework's path syntax is Bun's — `:param` and a trailing `*` —
|
|
5
|
+
* while OpenAPI templates variables as `{param}`. The conversion happens
|
|
6
|
+
* here, together with the parameter objects the template then requires:
|
|
7
|
+
* OpenAPI demands that every variable in a path be declared.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AnySchema } from "@tetsujs/core";
|
|
13
|
+
import type { ParameterObject } from "./document.ts";
|
|
14
|
+
import { emitted } from "./emit.ts";
|
|
15
|
+
|
|
16
|
+
/** The name a wildcard segment is documented under. */
|
|
17
|
+
export const wildcardName = "wildcard";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rewrites a route path as an OpenAPI path template.
|
|
21
|
+
*
|
|
22
|
+
* A wildcard becomes an ordinary variable: OpenAPI has no syntax for
|
|
23
|
+
* "everything below here", so the closest honest template is a named
|
|
24
|
+
* segment, documented as such.
|
|
25
|
+
*/
|
|
26
|
+
export function toTemplate(path: string): string {
|
|
27
|
+
return path
|
|
28
|
+
.split("/")
|
|
29
|
+
.map((segment) => {
|
|
30
|
+
if (segment.startsWith(":")) {
|
|
31
|
+
return `{${segment.slice(1)}}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return segment === "*" ? `{${wildcardName}}` : segment;
|
|
35
|
+
})
|
|
36
|
+
.join("/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The variables a path template declares, in order. */
|
|
40
|
+
export function templateNames(path: string): string[] {
|
|
41
|
+
const names: string[] = [];
|
|
42
|
+
|
|
43
|
+
for (const segment of path.split("/")) {
|
|
44
|
+
if (segment.startsWith(":")) {
|
|
45
|
+
names.push(segment.slice(1));
|
|
46
|
+
} else if (segment === "*") {
|
|
47
|
+
names.push(wildcardName);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return names;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Describes the path parameters of a route.
|
|
56
|
+
*
|
|
57
|
+
* A `params` schema describes the whole object, so each variable takes the
|
|
58
|
+
* property of the same name; without one — or without a property for a
|
|
59
|
+
* given variable — the value is what the router captured: a string.
|
|
60
|
+
*/
|
|
61
|
+
export function pathParameters(
|
|
62
|
+
path: string,
|
|
63
|
+
schema: AnySchema | undefined,
|
|
64
|
+
warn: (message: string) => void,
|
|
65
|
+
): ParameterObject[] {
|
|
66
|
+
const properties = objectProperties(schema, "params", warn);
|
|
67
|
+
|
|
68
|
+
return templateNames(path).map((name) => ({
|
|
69
|
+
name,
|
|
70
|
+
in: "path" as const,
|
|
71
|
+
required: true,
|
|
72
|
+
...(name === wildcardName
|
|
73
|
+
? {
|
|
74
|
+
description:
|
|
75
|
+
"Matches the rest of the path, including further segments.",
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
schema: properties[name] ?? { type: "string" },
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Describes the query, header or cookie parameters of a route.
|
|
84
|
+
*
|
|
85
|
+
* All three are objects at the schema level and lists of parameters in a
|
|
86
|
+
* document, so each declared property becomes one parameter, required when
|
|
87
|
+
* the schema says so.
|
|
88
|
+
*
|
|
89
|
+
* A session cookie is the exception the specification makes itself: a
|
|
90
|
+
* credential belongs in `securitySchemes` as `apiKey in: cookie`, not
|
|
91
|
+
* among the parameters. Nothing here can tell one cookie from the other —
|
|
92
|
+
* that is what an annotation on the hook reading it is for, the same way
|
|
93
|
+
* `secured()` marks an authenticating hook.
|
|
94
|
+
*/
|
|
95
|
+
export function partParameters(
|
|
96
|
+
schema: AnySchema | undefined,
|
|
97
|
+
where: "query" | "header" | "cookie",
|
|
98
|
+
warn: (message: string) => void,
|
|
99
|
+
): ParameterObject[] {
|
|
100
|
+
if (!schema) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const described = emitted(schema, `the ${where}`, warn, "input");
|
|
105
|
+
|
|
106
|
+
if (!described) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const properties = (described.properties ?? {}) as Record<
|
|
111
|
+
string,
|
|
112
|
+
Record<string, unknown>
|
|
113
|
+
>;
|
|
114
|
+
const required = new Set(
|
|
115
|
+
Array.isArray(described.required) ? (described.required as string[]) : [],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return Object.entries(properties).map(([name, property]) => ({
|
|
119
|
+
name,
|
|
120
|
+
in: where,
|
|
121
|
+
required: required.has(name),
|
|
122
|
+
schema: property,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function objectProperties(
|
|
127
|
+
schema: AnySchema | undefined,
|
|
128
|
+
where: string,
|
|
129
|
+
warn: (message: string) => void,
|
|
130
|
+
): Record<string, Record<string, unknown>> {
|
|
131
|
+
if (!schema) {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const described = emitted(schema, `the ${where}`, warn, "input");
|
|
136
|
+
|
|
137
|
+
if (!described) {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (described.properties ?? {}) as Record<
|
|
142
|
+
string,
|
|
143
|
+
Record<string, unknown>
|
|
144
|
+
>;
|
|
145
|
+
}
|