oaspect 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 +19 -0
- package/bin/oaspect.mjs +127 -0
- package/dist/oaspect.js +1988 -0
- package/dist/oaspect.js.map +7 -0
- package/dist/server.js +120 -0
- package/dist/ssr.js +23873 -0
- package/package.json +70 -0
- package/src/html.js +73 -0
- package/src/serve.js +83 -0
- package/src/server.d.ts +29 -0
- package/src/server.js +168 -0
- package/src/spec.js +35 -0
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oaspect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive API reference for OpenAPI documents: one script tag, a web component, a CLI and server helpers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://oaspect.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/oaspect/oaspect.git",
|
|
10
|
+
"directory": "packages/oaspect"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/oaspect/oaspect/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/oaspect.js",
|
|
15
|
+
"browser": "./dist/oaspect.js",
|
|
16
|
+
"unpkg": "./dist/oaspect.js",
|
|
17
|
+
"jsdelivr": "./dist/oaspect.js",
|
|
18
|
+
"bin": {
|
|
19
|
+
"oaspect": "./bin/oaspect.mjs"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./dist/oaspect.js",
|
|
23
|
+
"./server": {
|
|
24
|
+
"types": "./src/server.d.ts",
|
|
25
|
+
"import": "./dist/server.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"dist",
|
|
32
|
+
"src/html.js",
|
|
33
|
+
"src/serve.js",
|
|
34
|
+
"src/server.js",
|
|
35
|
+
"src/spec.js",
|
|
36
|
+
"src/server.d.ts",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"keywords": [
|
|
40
|
+
"openapi",
|
|
41
|
+
"swagger",
|
|
42
|
+
"api-documentation",
|
|
43
|
+
"api-reference",
|
|
44
|
+
"cli",
|
|
45
|
+
"web-component"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@oaspect/core": "^0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"esbuild": "^0.25.12",
|
|
55
|
+
"jsdom": "^26.1.0",
|
|
56
|
+
"react": "19.2.4",
|
|
57
|
+
"react-dom": "19.2.4",
|
|
58
|
+
"vitest": "^3.2.7",
|
|
59
|
+
"@oaspect/react": "^0.1.0"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "node scripts/build.mjs",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"typecheck": "true",
|
|
68
|
+
"lint": "true"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/html.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// HTML page around the standalone bundle, shared by `oaspect build` (inline
|
|
2
|
+
// script and spec: one self-contained file) and `oaspect serve`.
|
|
3
|
+
|
|
4
|
+
const escapeHtml = (value) =>
|
|
5
|
+
String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
|
6
|
+
|
|
7
|
+
// Default page icon: braces on a rounded square, inline so the file stays self-contained.
|
|
8
|
+
const DEFAULT_ICON =
|
|
9
|
+
"data:image/svg+xml," +
|
|
10
|
+
encodeURIComponent(
|
|
11
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#16a34a"/><path d="M12 9c-2 0-3 1-3 3v2c0 1-1 2-2 2 1 0 2 1 2 2v2c0 2 1 3 3 3M20 9c2 0 3 1 3 3v2c0 1 1 2 2 2-1 0-2 1-2 2v2c0 2-1 3-3 3" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg>',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// JSON inside <script>: "<" escaped so "</script>" in the data cannot close it.
|
|
15
|
+
const scriptJson = (value) =>
|
|
16
|
+
JSON.stringify(value)
|
|
17
|
+
.replace(/</g, "\\u003c")
|
|
18
|
+
.replace(/\u2028/g, "\\u2028")
|
|
19
|
+
.replace(/\u2029/g, "\\u2029");
|
|
20
|
+
|
|
21
|
+
// Sets <html data-theme> from the reader's stored or system preference before
|
|
22
|
+
// the first paint, so prerendered markup does not flash the wrong theme.
|
|
23
|
+
const themeScript = (storagePrefix) => `(() => {
|
|
24
|
+
let theme;
|
|
25
|
+
try { theme = localStorage.getItem(${scriptJson(`${storagePrefix}:theme`)}); } catch {}
|
|
26
|
+
if (theme !== "light" && theme !== "dark") theme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
27
|
+
document.documentElement.dataset.theme = theme;
|
|
28
|
+
})();`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{
|
|
32
|
+
* title?: string,
|
|
33
|
+
* config?: object, // Oaspect.init() config (specUrl, locale…)
|
|
34
|
+
* spec?: object, // inlined OpenAPI document
|
|
35
|
+
* script?: { src?: string, inline?: string },
|
|
36
|
+
* icon?: string, // favicon URL (default: built-in icon)
|
|
37
|
+
* markup?: string, // prerendered viewer HTML (hydrated instead of rendered)
|
|
38
|
+
* css?: string, // stylesheet to inline, needed with markup
|
|
39
|
+
* }} options
|
|
40
|
+
*/
|
|
41
|
+
export function renderHtml({ title = "API Reference", config = {}, spec, script = {}, icon = DEFAULT_ICON, markup, css }) {
|
|
42
|
+
const scriptTag = script.inline
|
|
43
|
+
? `<script>${script.inline.replace(/<\/script/gi, "<\\/script")}</script>`
|
|
44
|
+
: `<script src="${escapeHtml(script.src ?? "oaspect.js")}"></script>`;
|
|
45
|
+
const specTag = spec ? `\n <script type="application/json" id="oaspect-spec">${scriptJson(spec)}</script>` : "";
|
|
46
|
+
const configExpression = spec
|
|
47
|
+
? `Object.assign(${scriptJson(config)}, { spec: JSON.parse(document.getElementById("oaspect-spec").textContent) })`
|
|
48
|
+
: scriptJson(config);
|
|
49
|
+
const head = [
|
|
50
|
+
css ? `<style id="oaspect-styles">${css.replace(/<\/style/gi, "<\\/style")}</style>` : "",
|
|
51
|
+
config.theme ? "" : `<script>${themeScript(config.storagePrefix ?? "oaspect")}</script>`,
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join("\n ");
|
|
55
|
+
|
|
56
|
+
return `<!doctype html>
|
|
57
|
+
<html lang="${escapeHtml(config.locale ?? config.defaultLocale ?? "en")}">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8" />
|
|
60
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
61
|
+
<meta name="generator" content="oaspect" />
|
|
62
|
+
<title>${escapeHtml(title)}</title>
|
|
63
|
+
<link rel="icon" href="${escapeHtml(icon)}" />
|
|
64
|
+
<style>body { margin: 0 }</style>${head ? `\n ${head}` : ""}
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
<div id="oaspect">${markup ?? ""}</div>${specTag}
|
|
68
|
+
${scriptTag}
|
|
69
|
+
<script>Oaspect.${markup ? "hydrate" : "init"}("#oaspect", ${configExpression});</script>
|
|
70
|
+
</body>
|
|
71
|
+
</html>
|
|
72
|
+
`;
|
|
73
|
+
}
|
package/src/serve.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `oaspect serve`: local preview server. The spec is re-read on every request,
|
|
2
|
+
// so editing the file and reloading the page shows the change.
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { renderHtml } from "./html.js";
|
|
7
|
+
import { createProxyHandler } from "./server.js";
|
|
8
|
+
import { loadSpec } from "./spec.js";
|
|
9
|
+
|
|
10
|
+
const PROXY_PATH = "/__oaspect/proxy";
|
|
11
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
12
|
+
|
|
13
|
+
function toRequest(req, origin) {
|
|
14
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
15
|
+
return new Request(new URL(req.url, origin), {
|
|
16
|
+
method: req.method,
|
|
17
|
+
headers: Object.entries(req.headers).flatMap(([key, value]) => (Array.isArray(value) ? value.map((item) => [key, item]) : [[key, value]])),
|
|
18
|
+
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
19
|
+
duplex: hasBody ? "half" : undefined,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function send(res, response) {
|
|
24
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
25
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ source: string, port?: number, host?: string, proxy?: boolean | string[], bundlePath: string, config?: object }} options
|
|
30
|
+
*/
|
|
31
|
+
export async function serve({ source, port = 8080, host = "127.0.0.1", proxy = true, bundlePath, config = {} }) {
|
|
32
|
+
await loadSpec(source); // fail fast on a broken document
|
|
33
|
+
// An open relay on a non-loopback address would let the network reach
|
|
34
|
+
// anything this machine can see; require an explicit host list there.
|
|
35
|
+
if (proxy === true && !LOOPBACK.has(host)) {
|
|
36
|
+
throw new Error(`--host ${host} exposes the proxy to the network; pass --proxy-hosts api.example.com or --no-proxy.`);
|
|
37
|
+
}
|
|
38
|
+
const proxyHandler = proxy ? createProxyHandler({ allowedHosts: proxy === true ? "*" : proxy }) : null;
|
|
39
|
+
|
|
40
|
+
const html = () =>
|
|
41
|
+
renderHtml({
|
|
42
|
+
title: config.title ?? "API Reference",
|
|
43
|
+
config: { ...config, specUrl: "/openapi.json", proxyUrl: proxyHandler ? PROXY_PATH : null },
|
|
44
|
+
script: { src: "/oaspect.js" },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const server = createServer(async (req, res) => {
|
|
48
|
+
const origin = `http://${req.headers.host ?? `${host}:${port}`}`;
|
|
49
|
+
const { pathname } = new URL(req.url, origin);
|
|
50
|
+
try {
|
|
51
|
+
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
52
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
53
|
+
res.end(html());
|
|
54
|
+
} else if (req.method === "GET" && pathname === "/openapi.json") {
|
|
55
|
+
const spec = await loadSpec(source);
|
|
56
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
57
|
+
res.end(JSON.stringify(spec));
|
|
58
|
+
} else if (req.method === "GET" && pathname === "/oaspect.js") {
|
|
59
|
+
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
|
|
60
|
+
res.end(await readFile(bundlePath));
|
|
61
|
+
} else if (pathname === "/favicon.ico") {
|
|
62
|
+
// The page declares an inline icon; answer browsers that still ask.
|
|
63
|
+
res.writeHead(204);
|
|
64
|
+
res.end();
|
|
65
|
+
} else if (proxyHandler && pathname === PROXY_PATH) {
|
|
66
|
+
await send(res, await proxyHandler(toRequest(req, origin)));
|
|
67
|
+
} else {
|
|
68
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
69
|
+
res.end("Not found");
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
73
|
+
res.end(String(error.message ?? error));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
server.once("error", reject);
|
|
79
|
+
server.listen(port, host, resolve);
|
|
80
|
+
});
|
|
81
|
+
const address = server.address();
|
|
82
|
+
return { server, url: `http://${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}` };
|
|
83
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ProxyHandlerOptions {
|
|
2
|
+
/** Hosts ("api.example.com", "localhost:8080") the proxy may call, or "*" for any. */
|
|
3
|
+
allowedHosts: string[] | "*";
|
|
4
|
+
/** Upstream timeout. Default 30 s. */
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
/** Adjusts the target URL before fetching (e.g. map localhost inside Docker). */
|
|
7
|
+
rewriteHost?: (url: URL) => URL;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** POST handler relaying the viewer's "Try" requests; mount it at `proxyUrl`. */
|
|
11
|
+
export declare function createProxyHandler(options: ProxyHandlerOptions): (request: Request) => Promise<Response>;
|
|
12
|
+
|
|
13
|
+
/** Builds FormData from the viewer's multipart parts ({ name, value } or { name, file: { name, type, data } }). */
|
|
14
|
+
export declare function formDataFromParts(parts: Array<{ name: string; value?: string; file?: { name?: string; type?: string; data: string } }>): FormData;
|
|
15
|
+
|
|
16
|
+
export interface SpecHandlerOptions {
|
|
17
|
+
/** Live document URL, fetched server-side and cached. */
|
|
18
|
+
url?: string;
|
|
19
|
+
/** Document text (or a function returning it) used when `url` fails or is not set. */
|
|
20
|
+
fallback?: string | (() => string | Promise<string>);
|
|
21
|
+
/** Cache lifetime for the live document. Default 60. */
|
|
22
|
+
cacheSeconds?: number;
|
|
23
|
+
/** Upstream timeout. Default 10 s. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
rewriteHost?: (url: URL) => URL;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** GET handler serving the spec; sets x-oaspect-spec-source to remote, fallback or static. */
|
|
29
|
+
export declare function createSpecHandler(options: SpecHandlerOptions): (request?: Request) => Promise<Response>;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Server helpers built on Web-standard Request/Response, so the same code
|
|
2
|
+
// runs on Node, Next.js route handlers, Hono, Bun, Deno or Cloudflare Workers.
|
|
3
|
+
|
|
4
|
+
const DROPPED_HEADERS = new Set([
|
|
5
|
+
"host",
|
|
6
|
+
"connection",
|
|
7
|
+
"content-length",
|
|
8
|
+
"transfer-encoding",
|
|
9
|
+
"keep-alive",
|
|
10
|
+
"upgrade",
|
|
11
|
+
"accept-encoding",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const json = (body, status = 200) => Response.json(body, { status });
|
|
15
|
+
|
|
16
|
+
function decodeBase64(data) {
|
|
17
|
+
const binary = atob(String(data ?? ""));
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// multipart parts from the viewer: { name, value } or
|
|
24
|
+
// { name, file: { name, type, data: base64 } }.
|
|
25
|
+
export function formDataFromParts(parts) {
|
|
26
|
+
const data = new FormData();
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
if (part?.file) {
|
|
29
|
+
const blob = new Blob([decodeBase64(part.file.data)], { type: part.file.type || "application/octet-stream" });
|
|
30
|
+
data.append(String(part.name), blob, String(part.file.name || "file"));
|
|
31
|
+
} else {
|
|
32
|
+
data.append(String(part?.name), String(part?.value ?? ""));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeHosts(allowedHosts) {
|
|
39
|
+
if (allowedHosts === "*") return "*";
|
|
40
|
+
return (allowedHosts ?? []).map((host) => String(host).trim().toLowerCase()).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a handler that relays the viewer's "Try" requests server-side, so
|
|
45
|
+
* APIs without CORS headers can be called. Mount it at the URL you pass to
|
|
46
|
+
* the viewer as `proxyUrl` (POST).
|
|
47
|
+
*
|
|
48
|
+
* Restrict `allowedHosts` to your API: an open relay lets anyone reach
|
|
49
|
+
* addresses your server can see (internal services, cloud metadata).
|
|
50
|
+
*
|
|
51
|
+
* @param {{
|
|
52
|
+
* allowedHosts: string[] | "*",
|
|
53
|
+
* timeoutMs?: number,
|
|
54
|
+
* rewriteHost?: (url: URL) => URL,
|
|
55
|
+
* }} options
|
|
56
|
+
* @returns {(request: Request) => Promise<Response>}
|
|
57
|
+
*/
|
|
58
|
+
export function createProxyHandler({ allowedHosts, timeoutMs = 30_000, rewriteHost } = {}) {
|
|
59
|
+
const hosts = normalizeHosts(allowedHosts);
|
|
60
|
+
if (hosts !== "*" && hosts.length === 0) {
|
|
61
|
+
throw new Error('createProxyHandler: allowedHosts must list at least one host (or be "*")');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const allowed = (target) =>
|
|
65
|
+
hosts === "*" || hosts.includes(target.host.toLowerCase()) || hosts.includes(target.hostname.toLowerCase());
|
|
66
|
+
|
|
67
|
+
return async function proxy(request) {
|
|
68
|
+
if (request.method !== "POST") return json({ error: "Method not allowed." }, 405);
|
|
69
|
+
|
|
70
|
+
let payload;
|
|
71
|
+
try {
|
|
72
|
+
payload = await request.json();
|
|
73
|
+
} catch {
|
|
74
|
+
return json({ error: "Invalid request body." }, 400);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { method = "GET", url, headers = {}, body, form } = payload ?? {};
|
|
78
|
+
let target;
|
|
79
|
+
try {
|
|
80
|
+
target = new URL(url);
|
|
81
|
+
} catch {
|
|
82
|
+
return json({ error: `Invalid URL: ${url}` }, 400);
|
|
83
|
+
}
|
|
84
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
85
|
+
return json({ error: "Only http and https are supported." }, 400);
|
|
86
|
+
}
|
|
87
|
+
if (!allowed(target)) return json({ error: `${target.host} is not an allowed host.` }, 403);
|
|
88
|
+
|
|
89
|
+
if (rewriteHost) target = rewriteHost(new URL(target));
|
|
90
|
+
|
|
91
|
+
const upperMethod = String(method).toUpperCase();
|
|
92
|
+
const multipart = Array.isArray(form);
|
|
93
|
+
// For multipart, fetch sets Content-Type itself (with the boundary).
|
|
94
|
+
const forwarded = Object.fromEntries(
|
|
95
|
+
Object.entries(headers).filter(([key]) => !DROPPED_HEADERS.has(key.toLowerCase()) && !(multipart && key.toLowerCase() === "content-type")),
|
|
96
|
+
);
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch(target, {
|
|
101
|
+
method: upperMethod,
|
|
102
|
+
headers: forwarded,
|
|
103
|
+
body: upperMethod === "GET" || upperMethod === "HEAD" ? undefined : multipart ? formDataFromParts(form) : body,
|
|
104
|
+
redirect: "manual",
|
|
105
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
106
|
+
});
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
return json({
|
|
109
|
+
status: response.status,
|
|
110
|
+
statusText: response.statusText,
|
|
111
|
+
headers: [...response.headers.entries()],
|
|
112
|
+
body: text,
|
|
113
|
+
duration: Date.now() - started,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const code = error.cause?.code ? ` (${error.cause.code})` : "";
|
|
117
|
+
return json({ error: `Could not reach ${target.origin}: ${error.message}${code}` }, 502);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a GET handler serving the OpenAPI document for the viewer's
|
|
124
|
+
* `specUrl`. With `url`, the live document is fetched server-side (no CORS
|
|
125
|
+
* needed on the API) and cached for `cacheSeconds`; when it cannot be
|
|
126
|
+
* reached, `fallback` is served and the response carries
|
|
127
|
+
* `x-oaspect-spec-source: fallback` so the viewer shows an out-of-date notice.
|
|
128
|
+
*
|
|
129
|
+
* @param {{
|
|
130
|
+
* url?: string,
|
|
131
|
+
* fallback?: string | (() => string | Promise<string>),
|
|
132
|
+
* cacheSeconds?: number,
|
|
133
|
+
* timeoutMs?: number,
|
|
134
|
+
* rewriteHost?: (url: URL) => URL,
|
|
135
|
+
* }} options
|
|
136
|
+
* @returns {(request?: Request) => Promise<Response>}
|
|
137
|
+
*/
|
|
138
|
+
export function createSpecHandler({ url, fallback, cacheSeconds = 60, timeoutMs = 10_000, rewriteHost } = {}) {
|
|
139
|
+
if (!url && fallback === undefined) throw new Error("createSpecHandler: pass url, fallback or both");
|
|
140
|
+
let cached = null;
|
|
141
|
+
|
|
142
|
+
const respond = (text, source, contentType) =>
|
|
143
|
+
new Response(text, {
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type": contentType ?? (text.trimStart().startsWith("{") ? "application/json; charset=utf-8" : "application/yaml; charset=utf-8"),
|
|
146
|
+
"Cache-Control": `public, max-age=${cacheSeconds}`,
|
|
147
|
+
"x-oaspect-spec-source": source,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return async function spec() {
|
|
152
|
+
if (url) {
|
|
153
|
+
if (cached && Date.now() - cached.at < cacheSeconds * 1000) return respond(cached.text, "remote", cached.type);
|
|
154
|
+
try {
|
|
155
|
+
const target = rewriteHost ? rewriteHost(new URL(url)) : url;
|
|
156
|
+
const response = await fetch(target, { signal: AbortSignal.timeout(timeoutMs) });
|
|
157
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
cached = { text, type: response.headers.get("content-type") ?? undefined, at: Date.now() };
|
|
160
|
+
return respond(text, "remote", cached.type);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (fallback === undefined) return json({ error: `Could not load ${url}: ${error.message}` }, 502);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const text = typeof fallback === "function" ? await fallback() : fallback;
|
|
166
|
+
return respond(text, url ? "fallback" : "static");
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Loads an OpenAPI document (JSON or YAML; Swagger 2.0 is converted) from a
|
|
2
|
+
// file path or URL for the CLI.
|
|
3
|
+
import { loadSpec as loadDocument } from "@oaspect/core";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
export function isUrl(source) {
|
|
9
|
+
return /^https?:\/\//i.test(source);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readSpecText(source) {
|
|
13
|
+
if (isUrl(source)) {
|
|
14
|
+
const response = await fetch(source);
|
|
15
|
+
if (!response.ok) throw new Error(`${source}: HTTP ${response.status}`);
|
|
16
|
+
return response.text();
|
|
17
|
+
}
|
|
18
|
+
return readFile(resolve(source), "utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// External $refs: file: URLs from disk, http(s) URLs over the network.
|
|
22
|
+
async function readUrl(url) {
|
|
23
|
+
if (url.startsWith("file:")) return readFile(fileURLToPath(url), "utf8");
|
|
24
|
+
return readSpecText(url);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadSpec(source) {
|
|
28
|
+
const text = await readSpecText(source);
|
|
29
|
+
const baseUrl = isUrl(source) ? source : pathToFileURL(resolve(source)).href;
|
|
30
|
+
try {
|
|
31
|
+
return await loadDocument(text, { baseUrl, read: readUrl });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`${source}: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|