@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/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("&", "&amp;")
135
+ .replaceAll("<", "&lt;")
136
+ .replaceAll(">", "&gt;")
137
+ .replaceAll('"', "&quot;")
138
+ .replaceAll("'", "&#39;");
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
+ }