Files
EonaCat.DoxaApi/DoxaApi/UI/Assets/app.js
T

2506 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.DoxaEnvironmentManager = (function () {
const KEY = 'DoxaApi.environments';
const ACTIVE = 'DoxaApi.activeEnvironment';
function load() {
const stored = localStorage.getItem(KEY);
if (stored) {
try { return JSON.parse(stored); } catch (e) { /* fall through to default below */ }
}
return [{ name: 'Local', url: window.location.origin }];
}
function save(v) { localStorage.setItem(KEY, JSON.stringify(v)); }
function active() { return localStorage.getItem(ACTIVE) || 'Local'; }
function currentUrl() { const env = load().find(x => x.name === active()); return env ? env.url : window.location.origin; }
window.getConfiguredBaseUrl = currentUrl;
function hideEnvironmentManager() {
const modal = document.getElementById('envManagerModal');
if (modal) modal.remove();
}
function show() {
const existing = document.getElementById('envManagerModal');
if (existing) existing.remove();
const envs = load();
const activeEnv = active();
const envCards = envs.map(env => `
<div class="env-card ${env.name === activeEnv ? 'active' : ''}"
data-env="${escapeHtml(env.name)}">
<div class="env-card-left">
<input
type="radio"
name="environment"
${env.name === activeEnv ? 'checked' : ''}
>
<div>
<div class="env-name">
${escapeHtml(env.name)}
${env.name === activeEnv
? '<span class="env-badge">ACTIVE</span>'
: ''}
</div>
<div class="env-url">
${escapeHtml(env.url)}
</div>
</div>
</div>
<div class="env-actions">
<button class="icon-btn danger delete-env"
data-env="${env.name}">
🗑
</button>
</div>
</div>
`).join('');
document.body.insertAdjacentHTML('beforeend', `
<div id="envManagerModal" class="env-overlay">
<div class="env-modal">
<div class="env-modal-header">
<div>
<h2>Environment Manager</h2>
<div class="env-subtitle">
Manage API environments and switch between them
</div>
</div>
<button class="env-close-btn"
id="closeEnvManager">
</button>
</div>
<div class="env-modal-body">
<div class="env-section-title">
Environments
</div>
<div class="env-list">
${envCards}
</div>
<div class="env-divider"></div>
<div class="env-section-title">
Add Environment
</div>
<div class="env-form">
<div class="field">
<label>Name</label>
<input id="newEnvName"
type="text"
placeholder="Development">
</div>
<div class="field">
<label>Base URL</label>
<input id="newEnvUrl"
type="url"
placeholder="https://api.example.com">
</div>
<button id="addEnvBtn"
class="tb-btn tb-btn-accent">
+ Add Environment
</button>
</div>
</div>
<div class="env-modal-footer">
<button id="cancelEnvManager"
class="tb-btn">
Close
</button>
</div>
</div>
</div>
`);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
document.addEventListener('click', e => {
// Close button
if (
e.target.id === 'closeEnvManager' ||
e.target.id === 'cancelEnvManager'
) {
window.DoxaEnvironmentManager.hide();
return;
}
// Overlay click
if (
e.target.classList &&
e.target.classList.contains('env-overlay')
) {
window.DoxaEnvironmentManager.hide();
return;
}
// Select environment
const card = e.target.closest('.env-card');
if (card && !e.target.closest('.env-actions')) {
localStorage.setItem(ACTIVE, card.dataset.env);
window.DoxaEnvironmentManager.hide();
window.DoxaEnvironmentManager.show();
return;
}
// Delete environment
const deleteBtn = e.target.closest('.delete-env');
if (deleteBtn) {
const envName = deleteBtn.dataset.env;
if (envName === 'Local') {
alert('The Local environment cannot be deleted.');
return;
}
const envs = load().filter(x => x.name !== envName);
window.DoxaEnvironmentManager.save(envs);
if (active() === envName) {
localStorage.setItem(ACTIVE, 'Local');
}
window.DoxaEnvironmentManager.hide();
window.DoxaEnvironmentManager.show();
return;
}
// Add environment
if (e.target.id === 'addEnvBtn') {
const name = document
.getElementById('newEnvName')
.value
.trim();
const url = document
.getElementById('newEnvUrl')
.value
.trim();
if (!name || !url) {
alert('Please enter both a name and URL.');
return;
}
try {
new URL(url);
} catch {
alert('Please enter a valid URL.');
return;
}
const envs = load();
if (envs.some(x => x.name === name)) {
alert('An environment with that name already exists.');
return;
}
envs.push({
name,
url
});
save(envs);
hide();
show();
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
hide();
}
});
return {
show,
currentUrl,
active,
hide: hideEnvironmentManager
};
})();
(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 activeCodeLang = "curl";
let mockMode = localStorage.getItem("DoxaApi.mockMode") === "1";
let collapsedGroups = new Set(JSON.parse(localStorage.getItem("DoxaApi.collapsed") || "[]"));
// schemeId -> { value } | { username, password } | { token }
let authCredentials = JSON.parse(localStorage.getItem("DoxaApi.auth") || "{}");
// Array of { id, method, path, status, elapsedMs, time, request, response }
let requestHistory = JSON.parse(localStorage.getItem("DoxaApi.history") || "[]");
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"),
modalRoot: document.getElementById("modalRoot"),
};
function persistAuth() {
localStorage.setItem("DoxaApi.auth", JSON.stringify(authCredentials));
updateAuthBadge();
}
function persistHistory() {
if (requestHistory.length > 30) requestHistory = requestHistory.slice(0, 30);
localStorage.setItem("DoxaApi.history", JSON.stringify(requestHistory));
}
function updateAuthBadge() {
const dot = document.getElementById("authBadgeDot");
if (!dot) return;
const hasAny = Object.keys(authCredentials).some((k) => credentialIsSet(authCredentials[k]));
dot.style.display = hasAny ? "block" : "none";
}
function credentialIsSet(cred) {
if (!cred) return false;
return !!(cred.value || cred.token || (cred.username && cred.password));
}
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 || {});
spec.securitySchemes = Object.assign(spec.securitySchemes || {}, importedSpec.securitySchemes || {});
} 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();
updateAuthBadge();
}
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;
}
function mockServerAvailable() {
return !!(spec.features && spec.features.mockServer);
}
function consoleAvailable() {
return !!(spec.features && spec.features.console);
}
// 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 class="overview-stat"><div class="overview-stat-value">${Object.keys(spec.securitySchemes || {}).length}</div><div class="overview-stat-label">Auth schemes</div></div>`;
html += `</div>`;
if (spec.securitySchemes && Object.keys(spec.securitySchemes).length > 0) {
html += `<h3 class="overview-section-title">Authentication</h3>`;
html += `<div class="security-badges" style="margin-bottom:18px;">`;
for (const [id, scheme] of Object.entries(spec.securitySchemes)) {
html += `<button class="security-badge" data-overview-auth="${escapeAttr(id)}" type="button" style="cursor:pointer;">
<svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2"/><path d="M8 11V7a4 4 0 018 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
${escapeHtml(authSchemeLabel(scheme))}
</button>`;
}
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>`;
}
if (endpoint.security && endpoint.security.length > 0) {
html += `<div class="security-badges">`;
for (const schemeId of endpoint.security) {
const scheme = (spec.securitySchemes || {})[schemeId];
html += `<span class="security-badge">
<svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2"/><path d="M8 11V7a4 4 0 018 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
${escapeHtml(scheme ? authSchemeLabel(scheme) : schemeId)}
</span>`;
}
html += `</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 authSchemeLabel(scheme) {
if (!scheme) return "Auth";
if (scheme.type === "apiKey") return `API key (${scheme.name || "key"})`;
if (scheme.type === "http" && scheme.scheme === "bearer") return "Bearer token";
if (scheme.type === "http" && scheme.scheme === "basic") return "Basic auth";
if (scheme.type === "oauth2") return "OAuth 2.0";
return scheme.id || "Auth";
}
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&lt;</span>${renderInlineType(schema.items, depth)}<span class="schema-punct">&gt;</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}">`;
const currentEnv = window.DoxaEnvironmentManager.active();
const currentBaseUrl = window.DoxaEnvironmentManager.currentUrl();
const fullUrl =
currentBaseUrl.replace(/\/$/, '') +
endpoint.path;
html += `
<div class="try-header">
<div class="try-header-main">
<div class="try-title">
<span class="live-dot"></span>
Try it
</div>
<div class="try-subtitle">
Execute requests against the selected environment
</div>
</div>
</div>
<div class="try-env-bar">
<div class="try-env-info">
<div class="try-env-label">
Environment
</div>
<div class="try-env-main">
<span class="try-env-badge">
${escapeHtml(currentEnv)}
</span>
<span class="try-env-url">
${escapeHtml(currentBaseUrl)}
</span>
</div>
</div>
<button
id="changeEnvironmentBtn"
class="tb-btn tb-btn-sm"
type="button">
Change
</button>
</div>
<div class="request-target">
<span class="request-method method-${endpoint.method.toLowerCase()}">
${endpoint.method}
</span>
<code class="request-url">
${escapeHtml(fullUrl)}
</code>
</div>
`;
html += `<div class="try-tabs">
<button class="try-tab ${activeTryTab === "body" ? "active" : ""}" data-try-tab="body">Request</button>
<button class="try-tab ${activeTryTab === "code" ? "active" : ""}" data-try-tab="code">Code</button>
<button class="try-tab ${activeTryTab === "history" ? "active" : ""}" data-try-tab="history">History</button>
</div>`;
html += `<div id="tryTabBody" style="${activeTryTab === "body" ? "" : "display:none;"}">`;
if (endpoint.security && endpoint.security.length > 0) {
const satisfied = endpoint.security.some((id) => credentialIsSet(authCredentials[id]));
html += `<div class="auth-banner ${satisfied ? "auth-set" : "auth-missing"}">
<svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2"/><path d="M8 11V7a4 4 0 018 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
${satisfied ? "Credentials will be sent with this request" : "This endpoint requires authorization"}
<button class="auth-banner-link" id="openAuthFromTry" type="button">${satisfied ? "Edit" : "Set up"}</button>
</div>`;
}
if (mockServerAvailable()) {
html += `<label class="mock-toggle-row">
<span class="mock-toggle-label">Send to mock server <span style="color:var(--text-2)">(fake response, no backend needed)</span></span>
<span class="switch">
<input type="checkbox" id="mockModeToggle" ${mockMode ? "checked" : ""} />
<span class="switch-slider"></span>
</span>
</label>`;
}
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="tryTabCode" style="${activeTryTab === "code" ? "" : "display:none;"}">`;
html += renderCodeTab(endpoint);
html += `</div>`;
html += `<div id="tryTabHistory" style="${activeTryTab === "history" ? "" : "display:none;"}">`;
html += renderHistoryTab(endpoint);
html += `</div>`;
html += `</div>`;
el.try.innerHTML = html;
document.getElementById("sendBtn").addEventListener("click", () => sendTryRequest(endpoint));
const mockToggle = document.getElementById("mockModeToggle");
if (mockToggle) {
mockToggle.addEventListener("change", () => {
mockMode = mockToggle.checked;
localStorage.setItem("DoxaApi.mockMode", mockMode ? "1" : "0");
});
}
const openAuthBtn = document.getElementById("openAuthFromTry");
if (openAuthBtn) {
openAuthBtn.addEventListener("click", () => openAuthModal());
}
el.try.querySelectorAll("[data-try-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
activeTryTab = btn.dataset.tryTab;
renderTry(group, endpoint);
});
});
bindCodeTabEvents(endpoint);
bindHistoryTabEvents(endpoint);
const changeEnvironmentBtn =
document.getElementById('changeEnvironmentBtn');
if (changeEnvironmentBtn) {
changeEnvironmentBtn.addEventListener('click', () => {
window.DoxaEnvironmentManager.show();
});
}
}
function renderCodeTab(endpoint) {
const langs = [
{ id: "curl", label: "cURL" },
{ id: "js", label: "JavaScript" },
{ id: "python", label: "Python" },
{ id: "csharp", label: "C#" },
{ id: "go", label: "Go" },
];
let html = `<div class="code-lang-tabs">`;
for (const lang of langs) {
html += `<button class="code-lang-tab ${activeCodeLang === lang.id ? "active" : ""}" data-code-lang="${lang.id}">${lang.label}</button>`;
}
html += `</div>`;
html += `<div class="curl-header-row">
<span class="field-label" style="margin-bottom:0;">${langs.find((l) => l.id === activeCodeLang).label} snippet</span>
<button class="copy-btn" id="copyCodeBtn">
<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>`;
html += `<div class="curl-box" id="codeSnippet">${buildCodeSnippet(endpoint, activeCodeLang)}</div>`;
return html;
}
function bindCodeTabEvents(endpoint) {
const root = document.getElementById("tryTabCode");
if (!root) return;
root.querySelectorAll("[data-code-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
activeCodeLang = btn.dataset.codeLang;
document.getElementById("tryTabCode").innerHTML = renderCodeTab(endpoint);
bindCodeTabEvents(endpoint);
});
});
const copyBtn = document.getElementById("copyCodeBtn");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
const text = document.getElementById("codeSnippet").textContent;
navigator.clipboard.writeText(text).then(() => {
const original = copyBtn.innerHTML;
copyBtn.textContent = "Copied";
setTimeout(() => (copyBtn.innerHTML = original), 1100);
});
});
}
}
function renderHistoryTab(endpoint) {
const items = requestHistory.filter((h) => h.operationId === endpoint.operationId);
if (items.length === 0) {
return `<div class="history-empty">No requests sent yet for this endpoint.<br/>Responses you send will appear here.</div>`;
}
let html = "";
for (const item of items) {
const cls = item.status && item.status < 300 ? "status-2xx" : item.status && item.status < 500 ? "status-4xx" : "status-5xx";
html += `<div class="history-item" data-history-id="${escapeAttr(item.id)}">
<div class="history-item-row">
<span class="status-pill ${cls}" style="padding:1px 7px;font-size:10px;">${item.status || "ERR"}</span>
<span class="history-item-time">${escapeHtml(item.time)}</span>
</div>
<div class="history-item-path">${escapeHtml(item.method)} ${escapeHtml(item.url)}</div>
</div>`;
}
return html;
}
function bindHistoryTabEvents(endpoint) {
const root = document.getElementById("tryTabHistory");
if (!root) return;
root.querySelectorAll("[data-history-id]").forEach((node) => {
node.addEventListener("click", () => {
const item = requestHistory.find((h) => h.id === node.dataset.historyId);
if (!item) return;
activeTryTab = "body";
renderTry({ name: "" }, endpoint);
showResponsePanelFromHistory(item);
});
});
}
function showResponsePanelFromHistory(item) {
const panel = document.getElementById("responsePanel");
if (!panel || !item.response) return;
renderResponsePanel(panel, item.response);
}
// Builds a language-agnostic description of the request: url, headers, body.
// Shared by every code-snippet generator and by the auth-aware "Send" flow.
function buildRequestPlan(endpoint) {
const base = (spec.servers && spec.servers[0]) || "";
const path = endpoint.path;
const headers = { Accept: "application/json" };
if (endpoint.requestBody) {
headers["Content-Type"] = endpoint.requestBody.contentType || "application/json";
}
applyAuthHeaders(endpoint, headers);
const body = endpoint.requestBody
? (endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0))
: null;
return { url: base + path, method: endpoint.method, headers, body };
}
// Mutates `headers` (and returns query/string additions where relevant) based on
// whichever security schemes apply to the endpoint and have stored credentials.
function applyAuthHeaders(endpoint, headers, queryParamsOut) {
if (!endpoint.security || endpoint.security.length === 0) return;
for (const schemeId of endpoint.security) {
const scheme = (spec.securitySchemes || {})[schemeId];
const cred = authCredentials[schemeId];
if (!scheme || !credentialIsSet(cred)) continue;
if (scheme.type === "apiKey") {
const headerName = scheme.name || "X-API-Key";
if ((scheme.in || "header") === "header") {
headers[headerName] = cred.value;
} else if (scheme.in === "query" && queryParamsOut) {
queryParamsOut[headerName] = cred.value;
}
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
headers["Authorization"] = `Bearer ${cred.token}`;
} else if (scheme.type === "http" && scheme.scheme === "basic") {
const encoded = typeof btoa === "function" ? btoa(`${cred.username}:${cred.password}`) : "";
headers["Authorization"] = `Basic ${encoded}`;
} else if (scheme.type === "oauth2") {
headers["Authorization"] = `Bearer ${cred.token}`;
}
// First matching satisfied scheme wins (schemes are an OR requirement).
break;
}
}
function buildCodeSnippet(endpoint, lang) {
const plan = buildRequestPlan(endpoint);
switch (lang) {
case "js": return buildJsSnippet(plan);
case "python": return buildPythonSnippet(plan);
case "csharp": return buildCSharpSnippet(plan);
case "go": return buildGoSnippet(plan);
default: return buildCurlSnippet(plan);
}
}
function buildCurlSnippet(plan) {
const lines = [];
lines.push(`<span class="curl-flag">curl</span> -X ${plan.method} \\`);
lines.push(` "${escapeHtml(plan.url)}" \\`);
const headerEntries = Object.entries(plan.headers);
headerEntries.forEach(([k, v], i) => {
const isLast = i === headerEntries.length - 1 && !plan.body;
lines.push(` -H "${escapeHtml(k)}: ${escapeHtml(v)}"${isLast ? "" : " \\"}`);
});
if (plan.body) {
lines.push(` -d <span class="curl-string">'${escapeHtml(plan.body)}'</span>`);
}
return lines.join("\n");
}
function buildJsSnippet(plan) {
const headersObj = JSON.stringify(plan.headers, null, 2).replace(/\n/g, "\n ");
const lines = [
`const response = await fetch("${plan.url}", {`,
` method: "${plan.method}",`,
` headers: ${headersObj},`,
];
if (plan.body) {
lines.push(` body: ${JSON.stringify(plan.body)},`);
}
lines.push(`});`);
lines.push(`const data = await response.json();`);
lines.push(`console.log(data);`);
return escapeHtml(lines.join("\n"));
}
function buildPythonSnippet(plan) {
const lines = [`import requests`, ``, `response = requests.request(`, ` "${plan.method}",`, ` "${plan.url}",`];
const headerLines = Object.entries(plan.headers).map(([k, v]) => ` "${k}": "${v}",`);
lines.push(` headers={`);
lines.push(...headerLines);
lines.push(` },`);
if (plan.body) {
lines.push(` data="""${plan.body}""",`);
}
lines.push(`)`);
lines.push(``);
lines.push(`print(response.status_code, response.json())`);
return escapeHtml(lines.join("\n"));
}
function buildCSharpSnippet(plan) {
const lines = [
`using var client = new HttpClient();`,
`using var request = new HttpRequestMessage(HttpMethod.${capitalize(plan.method.toLowerCase())}, "${plan.url}");`,
];
for (const [k, v] of Object.entries(plan.headers)) {
if (k.toLowerCase() === "content-type") continue;
lines.push(`request.Headers.TryAddWithoutValidation("${k}", "${v}");`);
}
if (plan.body) {
const contentType = plan.headers["Content-Type"] || "application/json";
lines.push(`request.Content = new StringContent(${JSON.stringify(plan.body)}, System.Text.Encoding.UTF8, "${contentType}");`);
}
lines.push(`using var response = await client.SendAsync(request);`);
lines.push(`var body = await response.Content.ReadAsStringAsync();`);
lines.push(`Console.WriteLine($"{(int)response.StatusCode}: {body}");`);
return escapeHtml(lines.join("\n"));
}
function buildGoSnippet(plan) {
const lines = [
`package main`,
``,
`import (`,
`\t"fmt"`,
`\t"io"`,
`\t"net/http"`,
`\t"strings"`,
`)`,
``,
`func main() {`,
];
if (plan.body) {
lines.push(`\tbody := strings.NewReader(\`${plan.body.replace(/`/g, "'")}\`)`);
lines.push(`\treq, _ := http.NewRequest("${plan.method}", "${plan.url}", body)`);
} else {
lines.push(`\treq, _ := http.NewRequest("${plan.method}", "${plan.url}", nil)`);
}
for (const [k, v] of Object.entries(plan.headers)) {
lines.push(`\treq.Header.Set("${k}", "${v}")`);
}
lines.push(`\tresp, err := http.DefaultClient.Do(req)`);
lines.push(`\tif err != nil {`);
lines.push(`\t\tpanic(err)`);
lines.push(`\t}`);
lines.push(`\tdefer resp.Body.Close()`);
lines.push(`\tdata, _ := io.ReadAll(resp.Body)`);
lines.push(`\tfmt.Println(resp.StatusCode, string(data))`);
lines.push(`}`);
return escapeHtml(lines.join("\n"));
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
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;
}
}
function getMockUrl() {
return new URL(window.location.href + "/mock");
}
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 isMock = mockMode && mockServerAvailable();
const url = isMock
? getMockUrl()
: new URL(path, (typeof window.getConfiguredBaseUrl === 'function') ? window.getConfiguredBaseUrl() : window.location.origin);
if (isMock) {
url.searchParams.set("operationId", endpoint.operationId);
}
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;
}
const authQueryParams = {};
if (!isMock) {
applyAuthHeaders(endpoint, headers, authQueryParams);
for (const [k, v] of Object.entries(authQueryParams)) {
url.searchParams.set(k, v);
}
}
btn.disabled = true;
label.textContent = "Sending…";
btn.querySelector("svg").style.display = "none";
btn.insertBefore(spinnerEl(), btn.firstChild);
const startTime = performance.now();
let resultForHistory = null;
try {
const res = await fetch(url.toString(), {
method: isMock ? "GET" : endpoint.method,
headers: isMock ? { Accept: "application/json" } : headers,
body: isMock || 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();
}
resultForHistory = {
status: res.status,
ok: res.ok,
elapsedMs,
body: bodyText,
isJson,
isMock,
};
renderResponsePanel(panel, resultForHistory);
} catch (err) {
const elapsedMs = Math.round(performance.now() - startTime);
resultForHistory = {
status: null,
ok: false,
elapsedMs,
body: String(err && err.message ? err.message : err),
isJson: false,
networkError: true,
};
renderResponsePanel(panel, resultForHistory);
} finally {
btn.disabled = false;
label.textContent = "Send request";
const spinner = btn.querySelector(".spinner");
if (spinner) spinner.remove();
btn.querySelector("svg").style.display = "";
requestHistory.unshift({
id: "h" + Date.now() + Math.random().toString(36).slice(2, 7),
operationId: endpoint.operationId,
method: isMock ? "GET (mock)" : endpoint.method,
url: url.toString(),
status: resultForHistory ? resultForHistory.status : null,
time: new Date().toLocaleTimeString(),
response: resultForHistory,
});
persistHistory();
}
}
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>
${result.isMock ? `<span class="mock-badge">
<svg viewBox="0 0 24 24" fill="none" width="11" height="11"><path d="M12 2L3 7v10l9 5 9-5V7l-9-5z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
Mock
</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>`;
}
);
}
// ---------------------------------------------------------------
// Modal system - lightweight, dependency-free, used for Auth,
// Console, and Diff. Only one modal is shown at a time.
// ---------------------------------------------------------------
function closeModal() {
if (!el.modalRoot) return;
el.modalRoot.innerHTML = "";
}
function openModal(title, bodyHtml, { wide } = {}) {
if (!el.modalRoot) return null;
el.modalRoot.innerHTML = `
<div class="modal-backdrop" id="modalBackdrop">
<div class="modal-box ${wide ? "modal-wide" : ""}" role="dialog" aria-modal="true">
<div class="modal-head">
<span class="modal-title">${escapeHtml(title)}</span>
<button class="modal-close" id="modalCloseBtn" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>
</button>
</div>
<div class="modal-body" id="modalBody">${bodyHtml}</div>
</div>
</div>`;
const backdrop = document.getElementById("modalBackdrop");
document.getElementById("modalCloseBtn").addEventListener("click", closeModal);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeModal();
});
const escHandler = (e) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
return document.getElementById("modalBody");
}
// ---------------------------------------------------------------
// Authorization modal - one card per security scheme declared by
// the API, with scheme-appropriate inputs (API key / bearer token /
// basic credentials / OAuth2 client credentials). Values are kept
// in localStorage so they survive reloads, and are applied to every
// "Try it" request, cURL snippet, and generated code sample whose
// endpoint references that scheme.
// ---------------------------------------------------------------
function openAuthModal() {
const schemes = (spec && spec.securitySchemes) || {};
const ids = Object.keys(schemes);
let body;
if (ids.length === 0) {
body = `<div class="history-empty">This API doesn't declare any security schemes.<br/>Configure them via <code>DoxaApiOptions.AddBearerAuth()</code> and similar helpers.</div>`;
} else {
body = ids.map((id) => authSchemeCardHtml(id, schemes[id])).join("");
}
const modalBody = openModal("Authorization", body, {});
if (!modalBody || ids.length === 0) return;
for (const id of ids) {
bindAuthSchemeCard(modalBody, id, schemes[id]);
}
}
function authSchemeCardHtml(id, scheme) {
const cred = authCredentials[id] || {};
const isSet = credentialIsSet(cred);
let fieldsHtml = "";
if (scheme.type === "apiKey") {
fieldsHtml = `<div class="field-group" style="margin-bottom:8px;">
<div class="field-sublabel">${escapeHtml(scheme.name || "API key")}<span class="type-hint">${escapeHtml(scheme.in || "header")}</span></div>
<input class="field-input" type="text" data-auth-field="value" placeholder="Enter API key" value="${escapeAttr(cred.value || "")}" />
</div>`;
} else if (scheme.type === "http" && scheme.scheme === "basic") {
fieldsHtml = `<div class="field-group" style="margin-bottom:8px;">
<div class="field-sublabel">Username</div>
<input class="field-input" type="text" data-auth-field="username" value="${escapeAttr(cred.username || "")}" />
</div>
<div class="field-group" style="margin-bottom:8px;">
<div class="field-sublabel">Password</div>
<input class="field-input" type="password" data-auth-field="password" value="${escapeAttr(cred.password || "")}" />
</div>`;
} else if (scheme.type === "oauth2") {
const flow = scheme.flows && (scheme.flows.clientCredentials || scheme.flows.authorizationCode);
fieldsHtml = `<div class="field-group" style="margin-bottom:8px;">
<div class="field-sublabel">Access token<span class="type-hint">paste a token, or fetch one separately</span></div>
<input class="field-input" type="text" data-auth-field="token" placeholder="Bearer token" value="${escapeAttr(cred.token || "")}" />
</div>
${flow && flow.tokenUrl ? `<div class="auth-scheme-desc">Token URL: <code>${escapeHtml(flow.tokenUrl)}</code></div>` : ""}`;
} else {
// http bearer (default) and any unrecognized http-style scheme
fieldsHtml = `<div class="field-group" style="margin-bottom:8px;">
<div class="field-sublabel">Bearer token${scheme.bearerFormat ? ` <span class="type-hint">${escapeHtml(scheme.bearerFormat)}</span>` : ""}</div>
<input class="field-input" type="text" data-auth-field="token" placeholder="eyJhbGciOi..." value="${escapeAttr(cred.token || "")}" />
</div>`;
}
return `<div class="auth-scheme-card" data-scheme-id="${escapeAttr(id)}">
<div class="auth-scheme-head">
<span class="auth-scheme-name">${escapeHtml(authSchemeLabel(scheme))}</span>
<span class="auth-scheme-type">${escapeHtml(scheme.type)}</span>
<span class="auth-scheme-status ${isSet ? "set" : ""}"></span>
</div>
${scheme.description ? `<p class="auth-scheme-desc">${escapeHtml(scheme.description)}</p>` : ""}
${fieldsHtml}
<button class="auth-clear-btn" data-auth-clear="${escapeAttr(id)}" type="button">Clear credentials</button>
</div>`;
}
function bindAuthSchemeCard(root, id, scheme) {
const card = root.querySelector(`[data-scheme-id="${cssEscape(id)}"]`);
if (!card) return;
const persistFromInputs = () => {
const cred = {};
card.querySelectorAll("[data-auth-field]").forEach((input) => {
cred[input.dataset.authField] = input.value;
});
authCredentials[id] = cred;
persistAuth();
const statusDot = card.querySelector(".auth-scheme-status");
if (statusDot) statusDot.classList.toggle("set", credentialIsSet(cred));
};
card.querySelectorAll("[data-auth-field]").forEach((input) => {
input.addEventListener("input", persistFromInputs);
});
const clearBtn = card.querySelector("[data-auth-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", () => {
delete authCredentials[id];
persistAuth();
card.querySelectorAll("[data-auth-field]").forEach((input) => (input.value = ""));
const statusDot = card.querySelector(".auth-scheme-status");
if (statusDot) statusDot.classList.remove("set");
});
}
}
function cssEscape(s) {
return String(s).replace(/["\\]/g, "\\$&");
}
// ---------------------------------------------------------------
// Console - a free-form HTTP request runner that isn't tied to any
// documented operation. Requests are sent server-side via the
// /{prefix}/console endpoint, which avoids CORS restrictions when
// pointing at a different host than the one serving the docs.
// ---------------------------------------------------------------
function openConsoleModal() {
const base = (spec.servers && spec.servers[0]) || "";
const body = `
<div class="console-trigger" style="margin-bottom:12px;">
<select class="field-input" id="consoleMethod" style="flex:0 0 110px;">
${["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].map((m) => `<option ${m === "GET" ? "selected" : ""}>${m}</option>`).join("")}
</select>
<input class="field-input request-url" id="consoleUrl" placeholder="https://api.example.com/resource" value="${escapeAttr(base)}" />
</div>
<div class="field-group" style="margin-bottom:10px;">
<div class="field-label">Headers <span style="color:var(--text-2);font-weight:400;">(one per line, "Name: value")</span></div>
<textarea class="field-input" id="consoleHeaders" rows="3" spellcheck="false" placeholder="Authorization: Bearer ...&#10;Content-Type: application/json"></textarea>
</div>
<div class="field-group" style="margin-bottom:14px;">
<div class="field-label">Body</div>
<textarea class="field-input" id="consoleBody" rows="6" spellcheck="false" placeholder="{ }"></textarea>
</div>
<button class="send-btn" id="consoleSendBtn" style="--method-color:var(--accent);">
<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="consoleSendLabel">Send request</span>
</button>
<div id="consoleResponsePanel"></div>
`;
const modalBody = openModal("API Console", body, { wide: true });
if (!modalBody) return;
document.getElementById("consoleSendBtn").addEventListener("click", sendConsoleRequest);
}
function parseHeaderLines(text) {
const headers = {};
(text || "").split("\n").forEach((line) => {
const idx = line.indexOf(":");
if (idx === -1) return;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key) headers[key] = value;
});
return headers;
}
async function sendConsoleRequest() {
const btn = document.getElementById("consoleSendBtn");
const label = document.getElementById("consoleSendLabel");
const panel = document.getElementById("consoleResponsePanel");
const method = document.getElementById("consoleMethod").value;
const url = document.getElementById("consoleUrl").value.trim();
const headers = parseHeaderLines(document.getElementById("consoleHeaders").value);
const body = document.getElementById("consoleBody").value;
if (!url) {
panel.innerHTML = `<div class="history-empty">Enter a URL to send a request.</div>`;
return;
}
btn.disabled = true;
label.textContent = "Sending…";
const startTime = performance.now();
try {
const res = await fetch("console", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method, url, headers, body: body || undefined }),
});
const payload = await res.json();
const elapsedMs = payload.elapsedMs || Math.round(performance.now() - startTime);
let isJson = false;
let bodyText = payload.body || "";
try {
JSON.parse(bodyText);
isJson = true;
} catch {
isJson = false;
}
renderResponsePanel(panel, {
status: payload.statusCode,
ok: payload.statusCode >= 200 && payload.statusCode < 300,
elapsedMs,
body: isJson ? JSON.stringify(JSON.parse(bodyText), null, 2) : bodyText,
isJson,
networkError: !!payload.networkError,
});
} catch (err) {
renderResponsePanel(panel, {
status: null,
ok: false,
elapsedMs: Math.round(performance.now() - startTime),
body: String(err && err.message ? err.message : err),
isJson: false,
networkError: true,
});
} finally {
btn.disabled = false;
label.textContent = "Send request";
}
}
// ---------------------------------------------------------------
// Diff - compares the currently loaded spec against an uploaded
// OpenAPI / Swagger / DoxaApi JSON file and reports added, removed,
// and changed operations - useful for spotting breaking changes
// before shipping a new API version.
// ---------------------------------------------------------------
function computeSpecDiff(oldSpec, newSpec) {
const key = (ep) => `${ep.method} ${ep.path}`;
const oldOps = new Map();
const newOps = new Map();
for (const g of oldSpec.groups || []) for (const e of g.endpoints) oldOps.set(key(e), e);
for (const g of newSpec.groups || []) for (const e of g.endpoints) newOps.set(key(e), e);
const rows = [];
for (const [k, ep] of newOps) {
if (!oldOps.has(k)) rows.push({ tag: "added", key: k, summary: ep.summary });
}
for (const [k, ep] of oldOps) {
if (!newOps.has(k)) rows.push({ tag: "removed", key: k, summary: ep.summary });
}
for (const [k, oldEp] of oldOps) {
const newEp = newOps.get(k);
if (!newEp) continue;
const changes = [];
if ((oldEp.requestBody == null) !== (newEp.requestBody == null)) changes.push("request body");
if (JSON.stringify((oldEp.parameters || []).map((p) => p.name).sort()) !==
JSON.stringify((newEp.parameters || []).map((p) => p.name).sort())) changes.push("parameters");
if (JSON.stringify((oldEp.security || []).slice().sort()) !==
JSON.stringify((newEp.security || []).slice().sort())) changes.push("security");
if (!!oldEp.deprecated !== !!newEp.deprecated) changes.push("deprecation status");
if (changes.length > 0) {
rows.push({ tag: "changed", key: k, summary: changes.join(", ") });
}
}
return rows.sort((a, b) => a.key.localeCompare(b.key));
}
function openDiffModal(otherSpec) {
const rows = computeSpecDiff(spec, otherSpec);
const added = rows.filter((r) => r.tag === "added").length;
const removed = rows.filter((r) => r.tag === "removed").length;
const changed = rows.filter((r) => r.tag === "changed").length;
let body = `<div class="diff-summary">
<div class="diff-row"><span class="diff-tag diff-tag-added">+${added}</span> endpoints added</div>
<div class="diff-row"><span class="diff-tag diff-tag-removed">-${removed}</span> endpoints removed</div>
<div class="diff-row"><span class="diff-tag diff-tag-changed">~${changed}</span> endpoints changed</div>
</div>`;
if (rows.length === 0) {
body += `<div class="history-empty">No differences found between the current API and the uploaded spec.</div>`;
} else {
body += rows.map((r) => `<div class="diff-row">
<span class="diff-tag diff-tag-${r.tag}">${r.tag}</span>
<span><strong style="font-family:var(--font-mono);">${escapeHtml(r.key)}</strong>${r.summary ? ` - ${escapeHtml(r.summary)}` : ""}</span>
</div>`).join("");
}
openModal("Compare specs", body, { wide: true });
}
// Global events
function bindGlobalEvents() {
const importBtn = document.getElementById("importBtn");
const importFile = document.getElementById("importFile");
const exportToggle = document.querySelector(".export-toggle");
const exportDropdown = document.querySelector(".export-dropdown");
const exportMenu = document.getElementById("exportMenu");
const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn");
const exportOpenApiBtn = document.getElementById("exportOpenApiBtn");
const exportSwaggerBtn = document.getElementById("exportSwaggerBtn");
exportToggle?.addEventListener("click", (event) => {
event.stopPropagation();
const isOpen = exportMenu?.classList.toggle("open");
exportToggle.setAttribute("aria-expanded", String(!!isOpen));
});
document.addEventListener("click", (event) => {
if (!exportDropdown?.contains(event.target)) {
exportMenu?.classList.remove("open");
exportToggle?.setAttribute("aria-expanded", "false");
}
});
const dashboardBtn = document.getElementById("dashboardBtn");
const closeDashboard = document.getElementById("closeDashboard");
const dashboardPanel = document.getElementById("dashboardPanel");
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(new URL("import", document.baseURI), {
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 || {});
spec.securitySchemes = Object.assign(spec.securitySchemes || {}, importedSpec.securitySchemes || {});
} 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 = new URL(url, document.baseURI);
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"));
// Dashboard functionality
dashboardBtn?.addEventListener("click", () => openDashboard());
closeDashboard?.addEventListener("click", () => closeDashboardPanel());
const authBtn = document.getElementById("authBtn");
authBtn?.addEventListener("click", () => openAuthModal());
const consoleBtn = document.getElementById("consoleBtn");
if (consoleBtn) {
if (!consoleAvailable()) {
consoleBtn.style.display = "none";
} else {
consoleBtn.addEventListener("click", () => openConsoleModal());
}
}
const envBtn = document.getElementById("envBtn");
if (envBtn) {
document.addEventListener("click", (e) => {
if (e.target.closest('#envBtn')) {
window.DoxaEnvironmentManager.show();
return;
}
if (
e.target.id === 'closeEnvManager' ||
e.target.id === 'cancelEnvManager' ||
e.target.classList.contains('env-overlay')
) {
window.DoxaEnvironmentManager.hide();
return;
}
const card = e.target.closest('.env-card');
if (card) {
const envName = card.dataset.env;
window.DoxaEnvironmentManager.setActive(envName);
document
.querySelectorAll('.env-card')
.forEach(c => c.classList.remove('active'));
card.classList.add('active');
document
.querySelectorAll('input[name="environment"]')
.forEach(r => r.checked = false);
card.querySelector('input[type="radio"]').checked = true;
}
});
} else {
envBtn.addEventListener("click", () => showEnvironmentManager());
}
const diffFile = document.getElementById("diffFile");
const compareBtn = document.getElementById("compareBtn");
compareBtn?.addEventListener("click", () => diffFile?.click());
diffFile?.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
const normalized = parsed.groups
? parsed
: await (async () => {
const res = await fetch(new URL("import", document.baseURI), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: text
});
if (!res.ok) throw new Error(await res.text());
return res.json();
})();
openDiffModal(normalized);
} catch (err) {
alert("Couldn't compare spec: " + (err && err.message ? err.message : err));
} finally {
diffFile.value = "";
}
});
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 authBadge = e.target.closest("[data-overview-auth]");
if (authBadge) {
openAuthModal();
return;
}
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.ctrlKey && 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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c]));
}
function escapeAttr(str) {
return escapeHtml(str);
}
// Dashboard functions
async function openDashboard() {
const dashboardPanel = document.getElementById("dashboardPanel");
const dashboard = document.querySelector(".dashboard-panel");
if (!dashboardPanel) return;
dashboardPanel.style.display = "block";
try {
const res = await fetch(new URL("analytics", document.baseURI));
if (!res.ok) throw new Error("Failed to load analytics");
const analyticsData = await res.json();
updateDashboard(analyticsData);
// Refresh every 5 seconds
if (window.dashboardInterval) clearInterval(window.dashboardInterval);
window.dashboardInterval = setInterval(async () => {
const res = await fetch(new URL("analytics", document.baseURI));
if (res.ok) {
const data = await res.json();
updateDashboard(data);
}
}, 5000);
} catch (err) {
console.error("Dashboard load error:", err);
}
}
function closeDashboardPanel() {
const dashboardPanel = document.getElementById("dashboardPanel");
if (dashboardPanel) dashboardPanel.style.display = "none";
if (window.dashboardInterval) {
clearInterval(window.dashboardInterval);
window.dashboardInterval = null;
}
}
function updateDashboard(data) {
document.getElementById("totalEndpoints").textContent = data.totalEndpoints || "0";
document.getElementById("totalRequests").textContent = data.totalRequests || "0";
document.getElementById("totalErrors").textContent = data.totalErrors || "0";
document.getElementById("avgResponse").textContent = Math.round((data.averageResponseTime || 0)) + "ms";
// Update top endpoints
const topEndpointsList = (data.topEndpoints || []).map(ep => `
<div class="request-item">
<div>
<span class="request-method">${ep.method}</span>
<code style="font-size: 11px; color: var(--text-1);">${ep.path}</code>
</div>
<div class="request-time">${ep.requestCount} req</div>
</div>
`).join("") || "<div style='padding: 20px; color: var(--text-2); text-align: center;'>No data yet</div>";
document.getElementById("topEndpointsChart").innerHTML = `
<div style="width: 100%;">
<h3 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600;">Top Endpoints</h3>
${topEndpointsList}
</div>
`;
// Update recent requests
const recentRequestsList = (data.recentRequests || []).map(req => {
const statusClass = req.statusCode >= 400 ? "request-status-error" : "request-status-success";
return `
<div class="request-item">
<div>
<span class="request-method">${req.method}</span>
<code style="font-size: 11px; color: var(--text-1);">${req.path}</code>
</div>
<div>
<span class="request-time ${statusClass}">${req.statusCode}</span>
<span class="request-time" style="margin-left: 8px;">${req.responseTimeMs}ms</span>
</div>
</div>
`;
}).join("") || "<div style='padding: 20px; color: var(--text-2); text-align: center;'>No requests yet</div>";
document.getElementById("recentRequestsTable").innerHTML = `
<div style="padding: 16px; background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-md);">
<h3 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600;">Recent Requests</h3>
${recentRequestsList}
</div>
`;
}
/**
* Enhanced Try Pane - Advanced Request Builder
* Integrates RequestBuilder and CodeGenerator modules
*/
function renderAdvancedTryPane(group, endpoint) {
if (!endpoint) return renderTryEmpty();
const paramState = new RequestBuilder.ParameterState();
RequestBuilder.populateDefaults(endpoint, paramState);
let html = `
<div class="try-wrap">
<div class="try-header">
<div class="try-title">${escapeHtml(endpoint.method)} ${escapeHtml(endpoint.path)}</div>
${endpoint.deprecated ? '<span class="deprecated-badge">DEPRECATED</span>' : ''}
</div>
<div class="params-editor">
`;
// Path parameters
if (endpoint.parameters?.filter(p => p.in === 'path').length > 0) {
html += renderParamGroup('Path Parameters', endpoint.parameters.filter(p => p.in === 'path'), paramState);
}
// Query parameters
if (endpoint.parameters?.filter(p => p.in === 'query').length > 0) {
html += renderParamGroup('Query Parameters', endpoint.parameters.filter(p => p.in === 'query'), paramState);
}
// Headers
if (endpoint.parameters?.filter(p => p.in === 'header').length > 0) {
html += renderParamGroup('Headers', endpoint.parameters.filter(p => p.in === 'header'), paramState);
}
// Request body
if (endpoint.requestBody) {
html += `
<div class="param-group">
<div class="param-group-header">
<svg class="param-group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Request Body
</div>
<div class="param-items">
<div class="param-input" style="height: 200px; font-family: var(--font-mono); padding: 10px;" contenteditable="true" data-request-body="true">
${endpoint.requestBody.example ? JSON.stringify(endpoint.requestBody.example, null, 2) : '{}'}
</div>
</div>
</div>
`;
}
html += `</div>`;
// Code Generator
html += `
<div class="code-generator">
<div style="font-size: 12px; font-weight: 600; margin-bottom: 8px;">Generate Code</div>
<div class="code-lang-select" id="codeLangSelect">
`;
CodeGenerator.getSupportedLanguages().forEach(lang => {
html += `<button class="code-lang-btn" data-lang="${lang}">${lang}</button>`;
});
html += `
</div>
<div class="code-block" id="codeBlock">
// Select a language above
</div>
<button class="code-copy-btn" id="codeCopyBtn">Copy Code</button>
</div>
`;
// Response preview area
html += `
<div class="response-preview" style="display: none;" id="responsePreview">
<div class="response-header">
<span class="response-status" id="responseStatus"></span>
<span class="response-timing" id="responseTiming"></span>
</div>
<div class="response-tabs">
<button class="response-tab active" data-tab="body">Body</button>
<button class="response-tab" data-tab="headers">Headers</button>
</div>
<div class="response-body" id="responseBody"></div>
</div>
`;
html += `</div>`;
el.try.innerHTML = html;
// Bind code generator
document.querySelectorAll('.code-lang-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.code-lang-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeCodeLang = btn.dataset.lang;
updateCodePreview(endpoint, paramState);
});
});
// Initial code preview
updateCodePreview(endpoint, paramState);
// Copy code button
document.getElementById('codeCopyBtn')?.addEventListener('click', () => {
const code = document.getElementById('codeBlock').textContent;
navigator.clipboard.writeText(code).then(() => {
const btn = document.getElementById('codeCopyBtn');
btn.classList.add('copied');
btn.textContent = 'Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btn.textContent = 'Copy Code';
}, 2000);
});
});
}
/**
* Render parameter group
*/
function renderParamGroup(name, parameters, paramState) {
if (!parameters.length) return '';
const paramIn = parameters[0].in;
let html = `
<div class="param-group">
<div class="param-group-header" data-toggle-group="${paramIn}">
<svg class="param-group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
${name}
</div>
<div class="param-items">
`;
parameters.forEach(param => {
html += `
<div class="param-row ${param.required ? 'required' : ''}">
<div class="param-name" title="${escapeHtml(param.description || param.name)}">${escapeHtml(param.name)}</div>
<input
type="text"
class="param-input"
data-param-in="${param.in}"
data-param-name="${param.name}"
placeholder="${param.schema?.description || ''}"
value="${paramState[param.in]?.[param.name] || ''}"
/>
<div class="param-type">${param.schema?.type || 'string'}</div>
<button class="param-remove" title="Remove parameter">×</button>
</div>
`;
});
html += `</div></div>`;
return html;
}
/**
* Update code preview based on current parameters
*/
function updateCodePreview(endpoint, paramState) {
const currentLangBtn = document.querySelector('.code-lang-btn.active');
const lang = currentLangBtn?.dataset.lang || 'curl';
// Gather current param values from UI
document.querySelectorAll('.param-input').forEach(input => {
const paramIn = input.dataset.paramIn;
const paramName = input.dataset.paramName;
if (paramIn && paramName) {
paramState[paramIn][paramName] = input.value;
}
});
// Get request body if present
const bodyInput = document.querySelector('[data-request-body="true"]');
if (bodyInput) {
try {
paramState.body = JSON.parse(bodyInput.textContent);
} catch (e) {
paramState.body = bodyInput.textContent;
}
}
const request = RequestBuilder.buildRequest(endpoint, paramState, (typeof window.getConfiguredBaseUrl === 'function') ? window.getConfiguredBaseUrl() : window.location.origin);
const code = CodeGenerator.generate(lang, request);
document.getElementById('codeBlock').textContent = code;
}
/**
* Integration point for workspace manager
*/
function initializeWorkspaceManager() {
// Load initial workspaces
const workspaces = WorkspaceManager.getWorkspaces();
if (workspaces.length === 0) {
const defaultWs = new WorkspaceManager.Workspace('My Workspace');
const defaultCol = new WorkspaceManager.Collection('API Collection');
defaultWs.collections.push(defaultCol);
WorkspaceManager.saveWorkspace(defaultWs);
}
// Setup environment selector if environments panel exists
const envList = document.querySelector('.env-list');
if (envList) {
const environments = WorkspaceManager.getEnvironments();
if (environments.length === 0) {
const defaultEnv = new WorkspaceManager.Environment('Default Environment');
WorkspaceManager.saveEnvironment(defaultEnv);
}
}
}
/**
* Save current request to collection
*/
function saveCurrentRequest() {
if (!activeEndpoint || !spec) return;
const name = prompt('Request name:', `${activeEndpoint.method} ${activeEndpoint.path}`);
if (!name) return;
const workspace = WorkspaceManager.getCurrentWorkspace();
const collection = workspace.collections[0];
if (!collection) return;
const saved = new WorkspaceManager.SavedRequest(
name,
activeEndpoint.method,
activeEndpoint.path
);
saved.description = activeEndpoint.description || '';
// Gather current parameters
document.querySelectorAll('.param-input').forEach(input => {
const paramIn = input.dataset.paramIn;
const paramName = input.dataset.paramName;
if (paramIn && paramName && input.value) {
saved.params[paramIn][paramName] = input.value;
}
});
// Save body
const bodyInput = document.querySelector('[data-request-body="true"]');
if (bodyInput) {
saved.body = bodyInput.textContent;
}
WorkspaceManager.saveRequestToCollection(workspace.id, collection.id, saved);
alert(`Request saved to "${collection.name}"`);
}
/**
* Keyboard shortcuts and accessibility
*/
function initializeKeyboardShortcuts() {
const shortcuts = {
'Key/': (e) => e.ctrlKey && document.getElementById('searchInput')?.focus(),
'KeyS': (e) => e.ctrlKey && saveCurrentRequest(),
'KeyK': (e) => e.ctrlKey && toggleCommandPalette(),
'Tab': (e) => handleTabNavigation(e),
'Escape': () => closeModals()
};
document.addEventListener('keydown', (e) => {
// Don't trigger shortcuts when typing in input fields
if (e.target.matches('input, textarea, [contenteditable]')) {
if (e.key === 'Escape') {
e.target.blur();
}
return;
}
const handler = shortcuts[e.code] || shortcuts[e.key];
if (handler) {
handler(e);
}
});
// Show keyboard shortcut hints
addShortcutHints();
}
/**
* Add visual keyboard shortcut hints to UI
*/
function addShortcutHints() {
const searchInput = document.getElementById('searchInput');
if (searchInput && !searchInput.parentElement.querySelector('.kbd')) {
const kbd = document.createElement('kbd');
kbd.className = 'kbd search-kbd';
kbd.textContent = 'Ctrl + /';
searchInput.parentElement.appendChild(kbd);
}
}
/**
* Toggle command palette (future enhancement)
*/
function toggleCommandPalette() {
// Placeholder for future command palette implementation
console.log('Command palette not yet implemented');
}
/**
* Handle Tab navigation for better accessibility
*/
function handleTabNavigation(e) {
const focusableElements = document.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement);
let nextIndex = e.shiftKey ? currentIndex - 1 : currentIndex + 1;
if (nextIndex >= focusableElements.length) nextIndex = 0;
if (nextIndex < 0) nextIndex = focusableElements.length - 1;
const nextElement = focusableElements[nextIndex];
if (nextElement) {
e.preventDefault();
nextElement.focus();
}
}
/**
* Close all open modals
*/
function closeModals() {
document.querySelectorAll('[role="dialog"], .modal, .drawer').forEach(modal => {
modal.style.display = 'none';
});
}
/**
* Enhance accessibility with ARIA labels
*/
function enhanceAccessibility() {
// Add ARIA roles and labels
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.setAttribute('role', 'navigation');
const detailPane = document.getElementById('detailPane');
if (detailPane) detailPane.setAttribute('role', 'main');
const tryPane = document.getElementById('tryPane');
if (tryPane) tryPane.setAttribute('role', 'complementary');
// Add aria-labels to icon buttons
document.querySelectorAll('.icon-btn').forEach(btn => {
if (!btn.getAttribute('aria-label')) {
btn.setAttribute('aria-label', btn.title || btn.textContent);
}
});
// Add roles to custom components
document.querySelectorAll('.param-group-header').forEach(header => {
header.setAttribute('role', 'button');
header.setAttribute('tabindex', '0');
});
document.querySelectorAll('[data-overview], [data-endpoint]').forEach(link => {
link.setAttribute('role', 'menuitem');
});
}
/**
* Add focus visible styles for keyboard navigation
*/
function initializeFocusStyles() {
const style = document.createElement('style');
style.textContent = `
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
`;
document.head.appendChild(style);
}
/**
* Enhanced error handling and user feedback
*/
function showNotification(message, type = 'info', duration = 3000) {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.setAttribute('role', 'status');
notification.setAttribute('aria-live', 'polite');
notification.textContent = message;
notification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 16px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-1);
z-index: 9999;
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(notification);
if (duration > 0) {
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => notification.remove(), 300);
}, duration);
}
return notification;
}
/**
* Improved error logging
*/
const originalConsoleError = console.error;
console.error = function (...args) {
originalConsoleError.apply(console, args);
const message = args[0]?.toString?.() || String(args[0]);
if (message && !message.includes('HTTP')) {
showNotification(`Error: ${message.substring(0, 50)}`, 'error', 5000);
}
};
/**
* Initialize all enhancements
*/
function initializeEnhancements() {
initializeWorkspaceManager();
initializeKeyboardShortcuts();
enhanceAccessibility();
initializeFocusStyles();
}
// Call on load
initializeEnhancements();
init();
})();