2506 lines
101 KiB
JavaScript
2506 lines
101 KiB
JavaScript
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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<</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}">`;
|
||
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 ... 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) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
}[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();
|
||
})(); |