rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
558 lines (483 loc) • 17.3 kB
JavaScript
import { generateExample } from "./schema-utils.js";
const STATUS_TEXTS = {
200: "OK",
201: "Created",
202: "Accepted",
204: "No Content",
301: "Moved Permanently",
302: "Found",
304: "Not Modified",
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
let originalFetch = null;
let originalXhrOpen = null;
let originalXhrSend = null;
let originalXhrSetRequestHeader = null;
/**
* One mock per owner (the element that turned it on), so several <rapi-doc> and <rapi-doc-mini>
* on a page can each mock their own API: `{ routes, config, cycle }`. Calls without an owner use
* DEFAULT_OWNER, so a page with one mocked element works as before.
*/
const mocks = new Map();
const DEFAULT_OWNER = Symbol("rapidoc-mock");
/**
* The status setting: a string of codes (`"200, 404"`) for every operation, or a map by
* operation (`{ "POST /store/order": "503, 200", "*": "200" }`, `*` the default), also as JSON
* text. Map keys are the spec's method and path template, method case-insensitive.
*/
function normalizeStatus(value) {
if (value && typeof value === "object") {
const map = {};
for (const [key, codes] of Object.entries(value)) {
const k = key.trim();
if (!k || !codes) continue;
map[k === "*" ? "*" : k.replace(/^(\w+)\s+/, (m, method) => `${method.toUpperCase()} `)] =
String(codes);
}
return map;
}
const text = typeof value === "string" ? value.trim() : "";
if (text.startsWith("{")) {
try {
return normalizeStatus(JSON.parse(text));
} catch {
return "";
}
}
return text;
}
/** The codes for a route: its own entry of a status map, else the map's `*`, else the string. */
function codesFor(status, routeKey) {
if (!status || typeof status === "string") return status || "";
return status[routeKey] ?? status["*"] ?? "";
}
function normalizeConfig(options = {}, current = {}) {
return {
statusCode:
options.statusCode !== undefined
? normalizeStatus(options.statusCode)
: current.statusCode || "",
statusStrategy:
options.statusStrategy !== undefined
? options.statusStrategy || "first"
: current.statusStrategy || "first",
delay: options.delay !== undefined ? Number(options.delay) || 0 : current.delay || 0,
log: options.log !== undefined ? options.log !== "false" : current.log !== false,
};
}
/**
* The bases a route can be requested under, from the OpenAPI servers: `{ origin, base }` per
* server (origin null for a relative server URL), plus the bare path (`{ origin: null, base: "" }`)
* so a request without the server's base path still matches.
*/
function extractServerBases(servers = []) {
const bases = new Map();
bases.set("|", { origin: null, base: "" });
servers.forEach((s) => {
const rawUrl = s.computedUrl || s.url || "";
if (!rawUrl) return;
try {
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
const u = new URL(rawUrl);
const p = u.pathname.replace(/\/+$/, "");
bases.set(`${u.origin}|${p}`, { origin: u.origin, base: p });
} else {
const p = rawUrl.replace(/\/+$/, "");
if (p) bases.set(`|${p}`, { origin: null, base: p.startsWith("/") ? p : `/${p}` });
}
} catch {
// Ignore invalid URL formats
}
});
return Array.from(bases.values());
}
/**
* Converts an OpenAPI path template (e.g. /pets/{id}) into a matching RegExp.
*/
function compileRoutePattern(pathTemplate, basePath = "") {
const cleanBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
const fullPath = cleanBase + (pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`);
// Replace {param} with a placeholder token to avoid escaping issues
const tokenized = fullPath.replace(/\{([a-zA-Z0-9_-]+)\}/g, "__PARAM_$1__");
// Escape special regex characters
const escaped = tokenized.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
// Replace placeholder token with regex capture group
const pattern = escaped.replace(/__PARAM_([a-zA-Z0-9_-]+)__/g, "(?<$1>[^/?#]+)");
return new RegExp(`^${pattern}(?:\\/)?$`);
}
/**
* Builds the route table from the resolved OpenAPI spec.
*/
export function compileRoutes(spec) {
const routes = [];
const rootBases = extractServerBases(spec?.servers);
if (!spec?.tags || !Array.isArray(spec.tags)) {
return routes;
}
const seenRoutes = new Set();
spec.tags.forEach((tag) => {
(tag.paths || []).forEach((pathItem) => {
const method = (pathItem.method || "GET").toUpperCase();
const pathTemplate = pathItem.path || "";
const routeKey = `${method} ${pathTemplate}`;
if (seenRoutes.has(routeKey)) {
return;
}
seenRoutes.add(routeKey);
const endpointBases =
pathItem.servers?.length > 0 ? extractServerBases(pathItem.servers) : rootBases;
const patterns = endpointBases.map(({ origin, base }) => ({
regex: compileRoutePattern(pathTemplate, base),
origin,
base,
}));
routes.push({
method,
pathTemplate,
routeKey,
patterns,
responses: pathItem.responses || {},
parameters: pathItem.parameters || [],
});
});
});
return routes;
}
/**
* Matches a request (method + URL) against every mock's routes. With several mocks, the most
* specific match wins: a route under the server's origin and base path beats one under the base
* path alone, which beats a bare path; ties go to the mock enabled first.
*/
function matchRoute(method, urlStr) {
if (!mocks.size || !urlStr) {
return null;
}
let reqUrl;
try {
const origin =
typeof window !== "undefined" && window.location?.href
? window.location.href
: "http://localhost";
reqUrl = new URL(urlStr, origin);
} catch {
return null;
}
const upperMethod = (method || "GET").toUpperCase();
const pathname = decodeURI(reqUrl.pathname);
let best = null;
for (const mock of mocks.values()) {
for (const route of mock.routes) {
if (route.method !== upperMethod) {
continue;
}
for (const pattern of route.patterns) {
const match = pathname.match(pattern.regex);
if (!match) continue;
const rank =
(pattern.base ? 1 : 0) + (pattern.origin && pattern.origin === reqUrl.origin ? 1 : 0);
if (!best || rank > best.rank) {
best = { mock, route, rank, pathParams: match.groups || {}, url: reqUrl };
}
}
}
}
return best;
}
/**
* Chooses the status code based on config and strategy (first, cycle, random).
*/
function selectStatusCode(configuredCodesStr, strategy, endpointKey, responses = {}, cycle) {
const codes = (configuredCodesStr || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (codes.length === 0) {
// Look for 2xx response defined in spec
const twoXx = Object.keys(responses).find((k) => k.startsWith("2"));
if (twoXx) return twoXx;
if ("default" in responses) return "default";
const firstCode = Object.keys(responses)[0];
return firstCode || "200";
}
if (strategy === "random") {
return codes[Math.floor(Math.random() * codes.length)];
}
if (strategy === "cycle" && cycle) {
const currentIndex = cycle.get(endpointKey) || 0;
const selected = codes[currentIndex % codes.length];
cycle.set(endpointKey, (currentIndex + 1) % codes.length);
return selected;
}
// 'first'
return codes[0];
}
/**
* Generates the mock response body and headers for a matched route.
*/
function buildMockResponsePayload(route, statusCode) {
const responseDef =
route.responses?.[statusCode] ||
route.responses?.[String(statusCode)] ||
route.responses?.["default"];
const headers = {
"X-Mock-Server": "RapiDoc",
"Access-Control-Allow-Origin": "*",
};
let body = "";
let contentType = "application/json";
if (responseDef?.content) {
const availableTypes = Object.keys(responseDef.content);
// Prefer json, then xml, then text, then first available
contentType =
availableTypes.find((t) => t.includes("json")) ||
availableTypes.find((t) => t.includes("xml")) ||
availableTypes.find((t) => t.includes("text")) ||
availableTypes[0] ||
"application/json";
const mediaObj = responseDef.content[contentType];
if (mediaObj) {
if (mediaObj.example !== undefined) {
body =
typeof mediaObj.example === "object"
? JSON.stringify(mediaObj.example, null, 2)
: String(mediaObj.example);
} else if (mediaObj.examples && Object.keys(mediaObj.examples).length > 0) {
const firstEg = Object.values(mediaObj.examples)[0]?.value;
if (firstEg !== undefined) {
body = typeof firstEg === "object" ? JSON.stringify(firstEg, null, 2) : String(firstEg);
}
}
if (!body && mediaObj.schema) {
const generated = generateExample(
mediaObj.schema,
contentType,
null,
null,
true,
false,
"text",
true,
);
if (generated && generated.length > 0) {
const val = generated[0].exampleValue;
body = typeof val === "object" ? JSON.stringify(val, null, 2) : String(val);
}
}
}
}
// Handle empty or default body when spec has no content defined
if (!body) {
if (String(statusCode) === "204") {
body = "";
} else if (contentType.includes("json")) {
const statusNum = Number(statusCode) || 200;
body = JSON.stringify(
{
status: statusNum,
message: STATUS_TEXTS[statusNum] || "Mock Response",
},
null,
2,
);
} else {
body = STATUS_TEXTS[Number(statusCode)] || "OK";
}
}
// Copy defined headers from spec if any
if (responseDef?.headers) {
Object.entries(responseDef.headers).forEach(([hdrKey, hdrDef]) => {
if (hdrDef.schema?.default !== undefined) {
headers[hdrKey] = String(hdrDef.schema.default);
} else if (hdrDef.example !== undefined) {
headers[hdrKey] = String(hdrDef.example);
}
});
}
if (String(statusCode) !== "204") {
headers["Content-Type"] = contentType;
}
return {
statusCode: Number(statusCode) || 200,
statusText:
STATUS_TEXTS[Number(statusCode)] || (statusCode === "default" ? "OK" : "Mock Response"),
headers,
body,
};
}
/** The status and payload of a matched request, by the mock that owns the route. */
function answer(match) {
const { config, cycle } = match.mock;
const statusCode = selectStatusCode(
codesFor(config.statusCode, match.route.routeKey),
config.statusStrategy,
match.route.routeKey,
match.route.responses,
cycle,
);
return { config, statusCode, payload: buildMockResponsePayload(match.route, statusCode) };
}
/**
* Enables the in-browser mock for `options.owner` (the element; omitted: a page-wide default),
* replacing that owner's routes and config. Other owners' mocks stay. window.fetch and
* XMLHttpRequest are patched once, while at least one mock is enabled.
*/
export function enableMockServer(spec, options = {}) {
const owner = options.owner || DEFAULT_OWNER;
const current = mocks.get(owner);
mocks.set(owner, {
routes: compileRoutes(spec),
config: normalizeConfig(options, current?.config),
cycle: new Map(),
});
if (typeof window === "undefined") {
return;
}
// --- Patch window.fetch ---
if (!originalFetch && window.fetch) {
originalFetch = window.fetch;
window.fetch = async function mockFetch(input, init = {}) {
if (!mocks.size) {
return originalFetch.apply(this, arguments);
}
let url = "";
let method = "GET";
if (typeof input === "string") {
url = input;
method = init?.method || "GET";
} else if (input instanceof URL) {
url = input.href;
method = init?.method || "GET";
} else if (input && typeof input === "object" && "url" in input) {
url = input.url;
method = init?.method || input.method || "GET";
}
const match = matchRoute(method, url);
if (!match) {
return originalFetch.apply(this, arguments);
}
const { config, payload } = answer(match);
if (config.delay > 0) {
await new Promise((resolve) => setTimeout(resolve, config.delay));
}
if (config.log) {
console.info(
`%c[RapiDoc Mock]%c ${method.toUpperCase()} ${match.url.pathname} ➔ ${payload.statusCode} ${payload.statusText}`,
"color:#10b981; font-weight:bold;",
"color:inherit;",
);
}
return new Response(payload.body, {
status: payload.statusCode,
statusText: payload.statusText,
headers: payload.headers,
});
};
}
// --- Patch XMLHttpRequest ---
if (!originalXhrOpen && window.XMLHttpRequest) {
originalXhrOpen = window.XMLHttpRequest.prototype.open;
originalXhrSend = window.XMLHttpRequest.prototype.send;
originalXhrSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader;
window.XMLHttpRequest.prototype.open = function mockXhrOpen(method, url, ...rest) {
this._mockMethod = method;
this._mockUrl = url;
this._mockHeaders = {};
return originalXhrOpen.apply(this, [method, url, ...rest]);
};
window.XMLHttpRequest.prototype.setRequestHeader = function mockXhrSetHeader(name, value) {
if (this._mockHeaders) {
this._mockHeaders[name] = value;
}
return originalXhrSetRequestHeader.apply(this, arguments);
};
window.XMLHttpRequest.prototype.send = function mockXhrSend() {
if (!mocks.size) {
return originalXhrSend.apply(this, arguments);
}
const match = matchRoute(this._mockMethod, this._mockUrl);
if (!match) {
return originalXhrSend.apply(this, arguments);
}
const { config, payload } = answer(match);
const delayMs = config.delay > 0 ? config.delay : 10;
setTimeout(() => {
if (config.log) {
console.info(
`%c[RapiDoc Mock (XHR)]%c ${this._mockMethod.toUpperCase()} ${match.url.pathname} ➔ ${payload.statusCode} ${payload.statusText}`,
"color:#10b981; font-weight:bold;",
"color:inherit;",
);
}
Object.defineProperty(this, "readyState", { value: 4, writable: true });
Object.defineProperty(this, "status", { value: payload.statusCode, writable: true });
Object.defineProperty(this, "statusText", { value: payload.statusText, writable: true });
Object.defineProperty(this, "responseText", { value: payload.body, writable: true });
Object.defineProperty(this, "response", { value: payload.body, writable: true });
const headerStr = Object.entries(payload.headers)
.map(([k, v]) => `${k}: ${v}\r\n`)
.join("");
this.getAllResponseHeaders = () => headerStr;
this.getResponseHeader = (name) => {
const lower = name.toLowerCase();
const found = Object.keys(payload.headers).find((k) => k.toLowerCase() === lower);
return found ? payload.headers[found] : null;
};
this.dispatchEvent(new Event("readystatechange"));
this.dispatchEvent(new Event("load"));
this.dispatchEvent(new Event("loadend"));
if (typeof this.onreadystatechange === "function") {
this.onreadystatechange();
}
if (typeof this.onload === "function") {
this.onload();
}
}, delayMs);
};
}
}
/**
* Disables the mock of `owner` (omitted: the page-wide default). The original fetch and
* XMLHttpRequest come back once no mock is enabled.
*/
export function disableMockServer(owner = DEFAULT_OWNER) {
mocks.delete(owner);
if (mocks.size) return;
if (typeof window !== "undefined") {
if (originalFetch) {
window.fetch = originalFetch;
originalFetch = null;
}
if (originalXhrOpen) {
window.XMLHttpRequest.prototype.open = originalXhrOpen;
window.XMLHttpRequest.prototype.send = originalXhrSend;
window.XMLHttpRequest.prototype.setRequestHeader = originalXhrSetRequestHeader;
originalXhrOpen = null;
originalXhrSend = null;
originalXhrSetRequestHeader = null;
}
}
}
/**
* Updates the options of `options.owner`'s mock (omitted: the page-wide default) without
* recompiling its routes. Does nothing for an owner without a mock.
*/
export function updateMockConfig(options = {}) {
const mock = mocks.get(options.owner || DEFAULT_OWNER);
if (mock) mock.config = normalizeConfig(options, mock.config);
}
/** Whether `owner`'s mock is enabled; without an owner, whether any mock is. */
export function isMockServerActive(owner) {
return owner ? mocks.has(owner) : mocks.size > 0;
}