856 lines
38 KiB
JavaScript
856 lines
38 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const SPEC_URL = window.__DOXA_API_SPEC_URL__ || "doxaApi.json";
|
|
|
|
let apis = [];
|
|
let activeApiIndex = 0;
|
|
let spec = null;
|
|
let activeEndpoint = null;
|
|
let activeTryTab = "body";
|
|
let collapsedGroups = new Set(JSON.parse(localStorage.getItem("DoxaApi.collapsed") || "[]"));
|
|
|
|
const el = {
|
|
nav: document.getElementById("navContent"),
|
|
detail: document.getElementById("detailContent"),
|
|
try: document.getElementById("tryContent"),
|
|
search: document.getElementById("searchInput"),
|
|
themeToggle: document.getElementById("themeToggle"),
|
|
brandTitle: document.getElementById("brandTitle"),
|
|
brandVersion: document.getElementById("brandVersion"),
|
|
apiSelector: document.getElementById("apiSelector"),
|
|
|
|
};
|
|
|
|
function refreshApiSelector(){
|
|
if(!el.apiSelector) return;
|
|
el.apiSelector.innerHTML = apis.map((a,i)=>`<option value="${i}">${(a.info&&a.info.title)||('API '+(i+1))}</option>`).join('');
|
|
el.apiSelector.value=String(activeApiIndex);
|
|
}
|
|
|
|
async function init() {
|
|
renderSkeleton();
|
|
try {
|
|
const res = await fetch(SPEC_URL);
|
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
|
|
const importedSpec = await res.json();
|
|
const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)");
|
|
|
|
if (extend) {
|
|
spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])];
|
|
spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{});
|
|
} else {
|
|
apis.push(importedSpec);
|
|
activeApiIndex = apis.length - 1;
|
|
spec = importedSpec;
|
|
}
|
|
|
|
refreshApiSelector();
|
|
if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title;
|
|
if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version;
|
|
|
|
} catch (err) {
|
|
el.nav.innerHTML = `<div class="nav-empty">Couldn't load doxaApi.json<br/><span style="color:var(--text-2)">${escapeHtml(String(err))}</span></div>`;
|
|
return;
|
|
}
|
|
|
|
if (spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title;
|
|
if (spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version;
|
|
document.title = (spec.info && spec.info.title) || "API Documentation";
|
|
|
|
renderNav("");
|
|
renderOverview();
|
|
renderTryEmpty();
|
|
bindGlobalEvents();
|
|
}
|
|
|
|
function renderSkeleton() {
|
|
el.nav.innerHTML = Array.from({ length: 6 })
|
|
.map(() => `<div class="skeleton" style="height:14px;margin:10px 12px;border-radius:4px;"></div>`)
|
|
.join("");
|
|
}
|
|
|
|
function totalEndpointCount() {
|
|
return (spec.groups || []).reduce((n, g) => n + g.endpoints.length, 0);
|
|
}
|
|
|
|
function totalSchemaCount() {
|
|
return Object.keys(spec.schemas || {}).length;
|
|
}
|
|
|
|
// Nav rendering
|
|
function renderNav(filterText) {
|
|
const term = filterText.trim().toLowerCase();
|
|
const groups = spec.groups || [];
|
|
|
|
let html = `<button class="nav-overview-link ${!activeEndpoint ? "active" : ""}" data-overview="1">
|
|
<svg viewBox="0 0 24 24" fill="none"><path d="M4 5H20M4 12H20M4 19H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
<span>Overview</span>
|
|
</button>`;
|
|
|
|
if (groups.length === 0) {
|
|
html += `<div class="nav-empty">No endpoints found.</div>`;
|
|
el.nav.innerHTML = html;
|
|
return;
|
|
}
|
|
|
|
let totalMatches = 0;
|
|
let groupHtml = "";
|
|
|
|
for (const group of groups) {
|
|
const endpoints = group.endpoints.filter((e) => matchesFilter(e, term));
|
|
if (term && endpoints.length === 0) continue;
|
|
totalMatches += endpoints.length;
|
|
|
|
const isCollapsed = collapsedGroups.has(group.name) && !term;
|
|
|
|
groupHtml += `
|
|
<div class="nav-group ${isCollapsed ? "collapsed" : ""}" data-group="${escapeAttr(group.name)}">
|
|
<button class="nav-group-header" data-toggle-group="${escapeAttr(group.name)}">
|
|
<svg class="chev" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
<span>${escapeHtml(group.name)}</span>
|
|
<span class="nav-group-count">${group.endpoints.length}</span>
|
|
</button>
|
|
<div class="nav-endpoints">
|
|
${endpoints.map((e) => navEndpointHtml(group, e)).join("")}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
if (term && totalMatches === 0) {
|
|
html += `<div class="nav-empty">No endpoints match "${escapeHtml(filterText)}"</div>`;
|
|
} else {
|
|
html += groupHtml;
|
|
}
|
|
|
|
el.nav.innerHTML = html;
|
|
}
|
|
|
|
function navEndpointHtml(group, endpoint) {
|
|
const isActive =
|
|
activeEndpoint &&
|
|
activeEndpoint.endpoint.operationId === endpoint.operationId;
|
|
return `
|
|
<button class="nav-endpoint ${isActive ? "active" : ""} ${endpoint.deprecated ? "deprecated" : ""}"
|
|
style="--method-color: var(--m-${endpoint.method.toLowerCase()}, var(--m-default))"
|
|
data-op="${escapeAttr(endpoint.operationId)}">
|
|
<span class="method-tag">${endpoint.method}</span>
|
|
<span class="nav-endpoint-path">${escapeHtml(endpoint.path)}</span>
|
|
</button>`;
|
|
}
|
|
|
|
function matchesFilter(endpoint, term) {
|
|
if (!term) return true;
|
|
return (
|
|
endpoint.path.toLowerCase().includes(term) ||
|
|
(endpoint.summary || "").toLowerCase().includes(term) ||
|
|
endpoint.method.toLowerCase().includes(term)
|
|
);
|
|
}
|
|
|
|
// Overview / welcome screen
|
|
function renderOverview() {
|
|
const info = spec.info || {};
|
|
const groups = spec.groups || [];
|
|
|
|
let html = `<div class="fade-in">`;
|
|
html += `<div class="overview-hero">`;
|
|
html += `<div class="overview-eyebrow">
|
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/><path d="M12 7V12L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
API reference
|
|
</div>`;
|
|
html += `<h1 class="overview-title">${escapeHtml(info.title || "API Documentation")}</h1>`;
|
|
if (info.description) {
|
|
html += `<p class="overview-desc">${escapeHtml(info.description)}</p>`;
|
|
}
|
|
html += `<div class="overview-meta">`;
|
|
if (info.version) {
|
|
html += `<span class="overview-pill">
|
|
<svg viewBox="0 0 24 24" fill="none"><path d="M3 12L12 3L21 12M5 10V20H19V10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
${escapeHtml(info.version)}
|
|
</span>`;
|
|
}
|
|
if (spec.servers && spec.servers.length) {
|
|
html += `<span class="overview-pill">
|
|
<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="2"/><path d="M7 9H17M7 13H13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
${escapeHtml(spec.servers[0])}
|
|
</span>`;
|
|
}
|
|
html += `</div>`;
|
|
html += `</div>`; // hero
|
|
|
|
html += `<div class="overview-stats">`;
|
|
html += `<div class="overview-stat"><div class="overview-stat-value">${groups.length}</div><div class="overview-stat-label">Groups</div></div>`;
|
|
html += `<div class="overview-stat"><div class="overview-stat-value">${totalEndpointCount()}</div><div class="overview-stat-label">Endpoints</div></div>`;
|
|
html += `<div class="overview-stat"><div class="overview-stat-value">${totalSchemaCount()}</div><div class="overview-stat-label">Schemas</div></div>`;
|
|
html += `</div>`;
|
|
|
|
html += `<h3 class="overview-section-title">Browse by group</h3>`;
|
|
for (const group of groups) {
|
|
html += `<div class="overview-group-card">
|
|
<div class="overview-group-card-header">
|
|
<span>${escapeHtml(group.name)}</span>
|
|
<span class="nav-group-count">${group.endpoints.length}</span>
|
|
</div>
|
|
<div class="overview-group-routes">`;
|
|
for (const ep of group.endpoints) {
|
|
html += `<div class="overview-route-row" data-op="${escapeAttr(ep.operationId)}"
|
|
style="--method-color: var(--m-${ep.method.toLowerCase()}, var(--m-default))">
|
|
<span class="method-badge">${ep.method}</span>
|
|
<span class="route-path">${escapeHtml(ep.path)}</span>
|
|
<span class="route-summary">${escapeHtml(ep.summary || "")}</span>
|
|
<svg class="route-arrow" viewBox="0 0 24 24" fill="none"><path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</div>`;
|
|
}
|
|
html += `</div></div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
el.detail.innerHTML = html;
|
|
el.detail.scrollTop = 0;
|
|
}
|
|
|
|
function renderTryEmpty() {
|
|
el.try.innerHTML = `<div class="try-empty">
|
|
<svg viewBox="0 0 24 24" fill="none" style="margin-left:auto;margin-right:auto;display:block;"><path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
Pick an endpoint to send a live request.
|
|
</div>`;
|
|
}
|
|
|
|
function selectEndpoint(group, endpoint) {
|
|
activeEndpoint = { group, endpoint };
|
|
activeTryTab = "body";
|
|
document.querySelectorAll(".nav-endpoint").forEach((b) => {
|
|
b.classList.toggle("active", b.dataset.op === endpoint.operationId);
|
|
});
|
|
const overviewLink = document.querySelector(".nav-overview-link");
|
|
if (overviewLink) overviewLink.classList.remove("active");
|
|
renderDetail(group, endpoint);
|
|
renderTry(group, endpoint);
|
|
}
|
|
|
|
function renderDetail(group, endpoint) {
|
|
const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`;
|
|
|
|
let html = `<div class="fade-in">`;
|
|
html += `<div class="endpoint-header">`;
|
|
html += `<div class="breadcrumb">
|
|
<span>${escapeHtml(group.name)}</span>
|
|
<svg viewBox="0 0 24 24" fill="none"><path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
<span class="current">${escapeHtml(endpoint.summary || endpoint.operationId)}</span>
|
|
</div>`;
|
|
html += `<div class="request-line">
|
|
<span class="method-badge" style="--method-color:${methodColorVar}">${endpoint.method}</span>
|
|
<span class="endpoint-path">${escapeHtml(endpoint.path)}</span>
|
|
<button class="copy-route-btn" id="copyRouteBtn" title="Copy path" aria-label="Copy path">
|
|
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>
|
|
</button>
|
|
</div>`;
|
|
html += `<h1 class="endpoint-summary">${escapeHtml(endpoint.summary || endpoint.operationId)}</h1>`;
|
|
if (endpoint.description) {
|
|
html += `<p class="endpoint-description">${escapeHtml(endpoint.description)}</p>`;
|
|
}
|
|
if (endpoint.deprecated) {
|
|
html += `<div class="deprecated-banner">
|
|
<svg viewBox="0 0 24 24" fill="none"><path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18A2 2 0 003.54 21H20.46A2 2 0 0022.18 18L13.71 3.86A2 2 0 0010.29 3.86Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
Deprecated - this endpoint may be removed in a future version
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
if (endpoint.parameters && endpoint.parameters.length > 0) {
|
|
html += `<div class="section"><h3 class="section-title">Parameters <span class="count">${endpoint.parameters.length}</span></h3>`;
|
|
html += `<table class="param-table"><thead><tr><th>Name</th><th>Located in</th><th>Type</th><th>Description</th></tr></thead><tbody>`;
|
|
for (const p of endpoint.parameters) {
|
|
html += `<tr>
|
|
<td><span class="param-name">${escapeHtml(p.name)}${p.required ? '<span class="param-required">*</span>' : ""}</span></td>
|
|
<td><span class="param-loc">${p.in}</span></td>
|
|
<td><span class="param-type">${schemaTypeLabel(p.schema)}</span></td>
|
|
<td><span class="param-desc">${escapeHtml(p.description || "-")}</span></td>
|
|
</tr>`;
|
|
}
|
|
html += `</tbody></table></div>`;
|
|
}
|
|
|
|
if (endpoint.requestBody) {
|
|
html += `<div class="section"><h3 class="section-title">Request body</h3>`;
|
|
html += `<div class="schema-box">${renderSchemaTree(endpoint.requestBody.schema, 0)}</div></div>`;
|
|
}
|
|
|
|
if (endpoint.responses && endpoint.responses.length > 0) {
|
|
html += `<div class="section"><h3 class="section-title">Responses <span class="count">${endpoint.responses.length}</span></h3>`;
|
|
for (const r of endpoint.responses) {
|
|
const cls = r.statusCode[0] === "2" ? "status-2xx" : r.statusCode[0] === "4" ? "status-4xx" : "status-5xx";
|
|
html += `<div class="response-block">
|
|
<div class="response-block-header">
|
|
<span class="status-pill ${cls}">${r.statusCode}</span>
|
|
<span class="response-desc">${escapeHtml(r.description || "")}</span>
|
|
</div>`;
|
|
if (r.schema) {
|
|
html += `<div class="response-block-body"><div class="schema-box">${renderSchemaTree(r.schema, 0)}</div></div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
el.detail.innerHTML = html;
|
|
el.detail.scrollTop = 0;
|
|
|
|
const copyBtn = document.getElementById("copyRouteBtn");
|
|
if (copyBtn) {
|
|
copyBtn.addEventListener("click", () => {
|
|
navigator.clipboard.writeText(endpoint.path).then(() => flashIcon(copyBtn));
|
|
});
|
|
}
|
|
}
|
|
|
|
function flashIcon(btn) {
|
|
const original = btn.innerHTML;
|
|
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none"><path d="M5 13L9 17L19 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
setTimeout(() => (btn.innerHTML = original), 1100);
|
|
}
|
|
|
|
function schemaTypeLabel(schema) {
|
|
if (!schema) return "any";
|
|
if (schema.refName) return schema.refName;
|
|
if (schema.type === "array") return schemaTypeLabel(schema.items) + "[]";
|
|
if (schema.type === "enum") return "enum";
|
|
return schema.format ? `${schema.type} (${schema.format})` : schema.type;
|
|
}
|
|
|
|
function renderSchemaTree(schema, depth) {
|
|
if (!schema) return `<span class="schema-comment">unknown</span>`;
|
|
const indent = " ".repeat(depth);
|
|
|
|
if (schema.refName && spec.schemas && spec.schemas[schema.refName] && depth < 6) {
|
|
const resolved = spec.schemas[schema.refName];
|
|
return renderSchemaTree({ ...resolved, refName: undefined }, depth);
|
|
}
|
|
|
|
if (schema.type === "object" && schema.properties) {
|
|
const required = new Set(schema.required || []);
|
|
let lines = [`<span class="schema-punct">{</span>`];
|
|
const entries = Object.entries(schema.properties);
|
|
entries.forEach(([key, propSchema], i) => {
|
|
const isReq = required.has(key);
|
|
const comma = i < entries.length - 1 ? "," : "";
|
|
const nullableMark = propSchema && propSchema.nullable ? '<span class="schema-nullable-mark">?</span>' : "";
|
|
lines.push(
|
|
`${indent} <span class="schema-key">${escapeHtml(key)}</span>${isReq ? '<span class="schema-required-mark">*</span>' : ""}${nullableMark}<span class="schema-punct">:</span> ${renderInlineType(propSchema, depth + 1)}<span class="schema-punct">${comma}</span>`
|
|
);
|
|
});
|
|
lines.push(`${indent}<span class="schema-punct">}</span>`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
if (schema.type === "array") {
|
|
return `<span class="schema-punct">[</span>\n${indent} ${renderInlineType(schema.items, depth + 1)}\n${indent}<span class="schema-punct">]</span>`;
|
|
}
|
|
|
|
if (schema.type === "enum") {
|
|
return `<span class="schema-type">enum</span> <span class="schema-comment">(${(schema.enumValues || []).join(" | ")})</span>`;
|
|
}
|
|
|
|
return `<span class="schema-type">${schema.type}${schema.format ? " (" + schema.format + ")" : ""}</span>`;
|
|
}
|
|
|
|
function renderInlineType(schema, depth) {
|
|
if (!schema) return `<span class="schema-comment">any</span>`;
|
|
if (schema.refName) {
|
|
if (spec.schemas && spec.schemas[schema.refName] && depth < 6) {
|
|
return renderSchemaTree({ ...spec.schemas[schema.refName] }, depth);
|
|
}
|
|
return `<span class="schema-type">${escapeHtml(schema.refName)}</span>`;
|
|
}
|
|
if (schema.type === "object" && schema.properties) return renderSchemaTree(schema, depth);
|
|
if (schema.type === "array") {
|
|
return `<span class="schema-punct">Array<</span>${renderInlineType(schema.items, depth)}<span class="schema-punct">></span>`;
|
|
}
|
|
if (schema.type === "enum") {
|
|
return `<span class="schema-comment">(${(schema.enumValues || []).join(" | ")})</span>`;
|
|
}
|
|
return `<span class="schema-type">${schema.type}${schema.format ? " (" + schema.format + ")" : ""}</span>`;
|
|
}
|
|
|
|
// Try-it-out pane
|
|
function renderTry(group, endpoint) {
|
|
const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`;
|
|
let html = `<div class="fade-in" style="--method-color:${methodColorVar}">`;
|
|
html += `<div class="try-header">
|
|
<span class="try-title"><span class="live-dot"></span>Try it</span>
|
|
</div>`;
|
|
|
|
html += `<div class="try-tabs">
|
|
<button class="try-tab ${activeTryTab === "body" ? "active" : ""}" data-try-tab="body">Request</button>
|
|
<button class="try-tab ${activeTryTab === "curl" ? "active" : ""}" data-try-tab="curl">cURL</button>
|
|
</div>`;
|
|
|
|
html += `<div id="tryTabBody" style="${activeTryTab === "body" ? "" : "display:none;"}">`;
|
|
|
|
const pathParams = endpoint.parameters.filter((p) => p.in === "path");
|
|
const queryParams = endpoint.parameters.filter((p) => p.in === "query");
|
|
const headerParams = endpoint.parameters.filter((p) => p.in === "header");
|
|
|
|
if (pathParams.length) {
|
|
html += `<div class="field-group"><div class="field-label">Path parameters</div>`;
|
|
for (const p of pathParams) {
|
|
html += `<div style="margin-bottom:8px;">
|
|
<div class="field-sublabel">${escapeHtml(p.name)}${p.required ? '<span class="req-star">*</span>' : ""}<span class="type-hint">${schemaTypeLabel(p.schema)}</span></div>
|
|
<input class="field-input" data-param-in="path" data-param-name="${escapeAttr(p.name)}" placeholder="${schemaTypeLabel(p.schema)}" />
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
if (queryParams.length) {
|
|
html += `<div class="field-group"><div class="field-label">Query parameters</div>`;
|
|
for (const p of queryParams) {
|
|
html += `<div style="margin-bottom:8px;">
|
|
<div class="field-sublabel">${escapeHtml(p.name)}${p.required ? '<span class="req-star">*</span>' : ""}<span class="type-hint">${schemaTypeLabel(p.schema)}</span></div>
|
|
<input class="field-input" data-param-in="query" data-param-name="${escapeAttr(p.name)}" placeholder="${schemaTypeLabel(p.schema)}" />
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
if (headerParams.length) {
|
|
html += `<div class="field-group"><div class="field-label">Headers</div>`;
|
|
for (const p of headerParams) {
|
|
html += `<div style="margin-bottom:8px;">
|
|
<div class="field-sublabel">${escapeHtml(p.name)}${p.required ? '<span class="req-star">*</span>' : ""}<span class="type-hint">${schemaTypeLabel(p.schema)}</span></div>
|
|
<input class="field-input" data-param-in="header" data-param-name="${escapeAttr(p.name)}" placeholder="${schemaTypeLabel(p.schema)}" />
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
if (endpoint.requestBody) {
|
|
const example = endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0);
|
|
html += `<div class="field-group">
|
|
<div class="field-label">Request body <span style="color:var(--text-2);font-weight:400;">(JSON)</span></div>
|
|
<textarea class="field-input" id="tryBody" spellcheck="false">${escapeHtml(example)}</textarea>
|
|
</div>`;
|
|
}
|
|
|
|
html += `<button class="send-btn" id="sendBtn" style="--method-color:${methodColorVar}">
|
|
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
<span id="sendBtnLabel">Send request</span>
|
|
</button>`;
|
|
|
|
html += `<div id="responsePanel"></div>`;
|
|
html += `</div>`; // tryTabBody
|
|
|
|
html += `<div id="tryTabCurl" style="${activeTryTab === "curl" ? "" : "display:none;"}">
|
|
<div class="curl-header-row">
|
|
<span class="field-label" style="margin-bottom:0;">Shell snippet</span>
|
|
<button class="copy-btn" id="copyCurlBtn">
|
|
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
<div class="curl-box" id="curlSnippet">${buildCurlSnippet(endpoint)}</div>
|
|
</div>`;
|
|
|
|
html += `</div>`;
|
|
|
|
el.try.innerHTML = html;
|
|
|
|
document.getElementById("sendBtn").addEventListener("click", () => sendTryRequest(endpoint));
|
|
|
|
el.try.querySelectorAll("[data-try-tab]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
activeTryTab = btn.dataset.tryTab;
|
|
renderTry(group, endpoint);
|
|
});
|
|
});
|
|
|
|
const copyCurlBtn = document.getElementById("copyCurlBtn");
|
|
if (copyCurlBtn) {
|
|
copyCurlBtn.addEventListener("click", () => {
|
|
const text = document.getElementById("curlSnippet").textContent;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const original = copyCurlBtn.textContent;
|
|
copyCurlBtn.textContent = "Copied";
|
|
setTimeout(() => (copyCurlBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>Copy`), 1100);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildCurlSnippet(endpoint) {
|
|
const base = (spec.servers && spec.servers[0]) || "";
|
|
let path = endpoint.path;
|
|
// replace path params with placeholder tokens for readability
|
|
const lines = [];
|
|
lines.push(`<span class="curl-flag">curl</span> -X ${endpoint.method} \\`);
|
|
lines.push(` "${escapeHtml(base)}${escapeHtml(path)}" \\`);
|
|
lines.push(` -H "Accept: application/json"`);
|
|
if (endpoint.requestBody) {
|
|
const example = endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0);
|
|
lines[lines.length - 1] += " \\";
|
|
lines.push(` -H "Content-Type: ${escapeHtml(endpoint.requestBody.contentType || "application/json")}" \\`);
|
|
lines.push(` -d <span class="curl-string">'${escapeHtml(example)}'</span>`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function generateExampleJson(schema, depth) {
|
|
const value = generateExampleValue(schema, depth, new Set());
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function generateExampleValue(schema, depth, seen) {
|
|
if (!schema || depth > 6) return null;
|
|
|
|
if (schema.refName) {
|
|
if (seen.has(schema.refName)) return {};
|
|
const resolved = spec.schemas && spec.schemas[schema.refName];
|
|
if (!resolved) return {};
|
|
const nextSeen = new Set(seen);
|
|
nextSeen.add(schema.refName);
|
|
return generateExampleValue(resolved, depth + 1, nextSeen);
|
|
}
|
|
|
|
switch (schema.type) {
|
|
case "string":
|
|
if (schema.format === "date-time") return new Date().toISOString();
|
|
if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
return "string";
|
|
case "integer":
|
|
return 0;
|
|
case "number":
|
|
return 0;
|
|
case "boolean":
|
|
return true;
|
|
case "enum":
|
|
return (schema.enumValues && schema.enumValues[0]) || "string";
|
|
case "array":
|
|
return [generateExampleValue(schema.items, depth + 1, seen)];
|
|
case "object": {
|
|
if (!schema.properties) return {};
|
|
const obj = {};
|
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
obj[key] = generateExampleValue(propSchema, depth + 1, seen);
|
|
}
|
|
return obj;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function sendTryRequest(endpoint) {
|
|
const btn = document.getElementById("sendBtn");
|
|
const label = document.getElementById("sendBtnLabel");
|
|
const panel = document.getElementById("responsePanel");
|
|
|
|
let path = endpoint.path;
|
|
document.querySelectorAll('[data-param-in="path"]').forEach((input) => {
|
|
const name = input.dataset.paramName;
|
|
path = path.replace(`{${name}}`, encodeURIComponent(input.value || ""));
|
|
});
|
|
|
|
const url = new URL(path, window.location.origin);
|
|
document.querySelectorAll('[data-param-in="query"]').forEach((input) => {
|
|
if (input.value) url.searchParams.set(input.dataset.paramName, input.value);
|
|
});
|
|
|
|
const headers = { Accept: "application/json" };
|
|
document.querySelectorAll('[data-param-in="header"]').forEach((input) => {
|
|
if (input.value) headers[input.dataset.paramName] = input.value;
|
|
});
|
|
|
|
let body = undefined;
|
|
if (endpoint.requestBody) {
|
|
headers["Content-Type"] = endpoint.requestBody.contentType || "application/json";
|
|
const bodyEl = document.getElementById("tryBody");
|
|
body = bodyEl ? bodyEl.value : undefined;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
label.textContent = "Sending…";
|
|
btn.querySelector("svg").style.display = "none";
|
|
btn.insertBefore(spinnerEl(), btn.firstChild);
|
|
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
const res = await fetch(url.toString(), {
|
|
method: endpoint.method,
|
|
headers,
|
|
body: endpoint.method === "GET" || endpoint.method === "HEAD" ? undefined : body,
|
|
});
|
|
|
|
const elapsedMs = Math.round(performance.now() - startTime);
|
|
const contentType = res.headers.get("content-type") || "";
|
|
let bodyText;
|
|
let isJson = false;
|
|
|
|
if (contentType.includes("application/json")) {
|
|
try {
|
|
const json = await res.json();
|
|
bodyText = JSON.stringify(json, null, 2);
|
|
isJson = true;
|
|
} catch {
|
|
bodyText = await res.text();
|
|
}
|
|
} else {
|
|
bodyText = await res.text();
|
|
}
|
|
|
|
renderResponsePanel(panel, {
|
|
status: res.status,
|
|
ok: res.ok,
|
|
elapsedMs,
|
|
body: bodyText,
|
|
isJson,
|
|
});
|
|
} catch (err) {
|
|
const elapsedMs = Math.round(performance.now() - startTime);
|
|
renderResponsePanel(panel, {
|
|
status: null,
|
|
ok: false,
|
|
elapsedMs,
|
|
body: String(err && err.message ? err.message : err),
|
|
isJson: false,
|
|
networkError: true,
|
|
});
|
|
} finally {
|
|
btn.disabled = false;
|
|
label.textContent = "Send request";
|
|
const spinner = btn.querySelector(".spinner");
|
|
if (spinner) spinner.remove();
|
|
btn.querySelector("svg").style.display = "";
|
|
}
|
|
}
|
|
|
|
function spinnerEl() {
|
|
const s = document.createElement("span");
|
|
s.className = "spinner";
|
|
return s;
|
|
}
|
|
|
|
function renderResponsePanel(panel, result) {
|
|
const statusClass = result.networkError
|
|
? "status-5xx"
|
|
: result.status < 300
|
|
? "status-2xx"
|
|
: result.status < 500
|
|
? "status-4xx"
|
|
: "status-5xx";
|
|
|
|
const statusLabel = result.networkError ? "Network error" : result.status;
|
|
|
|
panel.innerHTML = `
|
|
<div class="response-panel fade-in">
|
|
<div class="response-meta">
|
|
<span class="status-pill ${statusClass}">${statusLabel}</span>
|
|
<span class="response-time">
|
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/><path d="M12 7V12L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
${result.elapsedMs} ms
|
|
</span>
|
|
<button class="copy-btn" id="copyResponseBtn" style="margin-left:auto;">
|
|
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
<div class="response-body ${result.networkError ? "response-error" : ""}" id="responseBody">${result.isJson ? syntaxHighlightJson(result.body) : escapeHtml(result.body)
|
|
}</div>
|
|
</div>`;
|
|
|
|
document.getElementById("copyResponseBtn").addEventListener("click", () => {
|
|
navigator.clipboard.writeText(result.body).then(() => {
|
|
const btn = document.getElementById("copyResponseBtn");
|
|
btn.lastChild.textContent = "Copied";
|
|
setTimeout(() => (btn.lastChild.textContent = "Copy"), 1200);
|
|
});
|
|
});
|
|
}
|
|
|
|
function syntaxHighlightJson(json) {
|
|
const escaped = escapeHtml(json);
|
|
return escaped.replace(
|
|
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g,
|
|
(match) => {
|
|
let cls = "json-number";
|
|
if (/^"/.test(match)) {
|
|
cls = /:$/.test(match) ? "json-key" : "json-string";
|
|
} else if (/true|false/.test(match)) {
|
|
cls = "json-boolean";
|
|
} else if (/null/.test(match)) {
|
|
cls = "json-null";
|
|
}
|
|
return `<span class="${cls}">${match}</span>`;
|
|
}
|
|
);
|
|
}
|
|
|
|
// Global events
|
|
function bindGlobalEvents()
|
|
{
|
|
const importBtn = document.getElementById("importBtn");
|
|
const importFile = document.getElementById("importFile");
|
|
const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn");
|
|
const exportOpenApiBtn = document.getElementById("exportOpenApiBtn");
|
|
const exportSwaggerBtn = document.getElementById("exportSwaggerBtn");
|
|
|
|
importBtn?.addEventListener("click", () => importFile.click());
|
|
|
|
importFile?.addEventListener("change", async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const text = await file.text();
|
|
|
|
const res = await fetch("import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: text
|
|
});
|
|
|
|
if (!res.ok) {
|
|
alert(await res.text());
|
|
return;
|
|
}
|
|
|
|
const importedSpec = await res.json();
|
|
const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)");
|
|
|
|
if (extend) {
|
|
spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])];
|
|
spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{});
|
|
} else {
|
|
apis.push(importedSpec);
|
|
activeApiIndex = apis.length - 1;
|
|
spec = importedSpec;
|
|
}
|
|
|
|
refreshApiSelector();
|
|
if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title;
|
|
if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version;
|
|
|
|
activeEndpoint = null;
|
|
|
|
renderNav("");
|
|
renderOverview();
|
|
renderTryEmpty();
|
|
});
|
|
|
|
function download(url, filename) {
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
|
|
exportDoxaApiBtn?.addEventListener("click", () => download("doxaApi.json", "DoxaApi.json"));
|
|
exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json"));
|
|
exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json"));
|
|
|
|
el.nav.addEventListener("click", (e) => {
|
|
const overviewBtn = e.target.closest("[data-overview]");
|
|
if (overviewBtn) {
|
|
activeEndpoint = null;
|
|
document.querySelectorAll(".nav-endpoint").forEach((b) => b.classList.remove("active"));
|
|
overviewBtn.classList.add("active");
|
|
renderOverview();
|
|
renderTryEmpty();
|
|
return;
|
|
}
|
|
|
|
const groupToggle = e.target.closest("[data-toggle-group]");
|
|
if (groupToggle) {
|
|
const name = groupToggle.dataset.toggleGroup;
|
|
if (collapsedGroups.has(name)) collapsedGroups.delete(name);
|
|
else collapsedGroups.add(name);
|
|
localStorage.setItem("DoxaApi.collapsed", JSON.stringify([...collapsedGroups]));
|
|
renderNav(el.search.value);
|
|
return;
|
|
}
|
|
|
|
const endpointBtn = e.target.closest("[data-op]");
|
|
if (endpointBtn) {
|
|
const opId = endpointBtn.dataset.op;
|
|
for (const group of spec.groups) {
|
|
const endpoint = group.endpoints.find((ep) => ep.operationId === opId);
|
|
if (endpoint) {
|
|
selectEndpoint(group, endpoint);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
el.detail.addEventListener("click", (e) => {
|
|
const row = e.target.closest("[data-op]");
|
|
if (!row) return;
|
|
const opId = row.dataset.op;
|
|
for (const group of spec.groups) {
|
|
const endpoint = group.endpoints.find((ep) => ep.operationId === opId);
|
|
if (endpoint) {
|
|
selectEndpoint(group, endpoint);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
el.search.addEventListener("input", () => renderNav(el.search.value));
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "/" && document.activeElement !== el.search) {
|
|
e.preventDefault();
|
|
el.search.focus();
|
|
}
|
|
if (e.key === "Escape" && document.activeElement === el.search) {
|
|
el.search.blur();
|
|
}
|
|
});
|
|
|
|
el.themeToggle.addEventListener("click", () => {
|
|
const root = document.documentElement;
|
|
const current = root.getAttribute("data-theme");
|
|
const isDark =
|
|
current === "dark" || (current === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
const next = isDark ? "light" : "dark";
|
|
root.setAttribute("data-theme", next);
|
|
localStorage.setItem("DoxaApi.theme", next);
|
|
});
|
|
|
|
const savedTheme = localStorage.getItem("DoxaApi.theme");
|
|
if (savedTheme) document.documentElement.setAttribute("data-theme", savedTheme);
|
|
}
|
|
|
|
// Helpers
|
|
function escapeHtml(str) {
|
|
return String(str ?? "").replace(/[&<>"']/g, (c) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
return escapeHtml(str);
|
|
}
|
|
|
|
init();
|
|
})();
|
|
|
|
window.DoxaApiPostmanLike = {
|
|
methods:["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"],
|
|
buildRequest:function(){
|
|
return {
|
|
method: document.getElementById("customMethod")?.value || "GET",
|
|
url: document.getElementById("customUrl")?.value || "",
|
|
headers: []
|
|
};
|
|
}
|
|
};
|