// retoor import { contentRenderer } from "./ContentRenderer.js"; import { CodeBlock } from "./CodeBlock.js"; const AUTH_METHODS = [ { id: "apikey", label: "X-API-KEY header" }, { id: "bearer", label: "Bearer token" }, { id: "basic", label: "HTTP Basic" }, { id: "session", label: "Session cookie" }, ]; const LANG_TABS = [ { id: "curl", label: "cURL", hl: "bash" }, { id: "javascript", label: "JavaScript", hl: "javascript" }, { id: "python", label: "Python", hl: "python" }, ]; const FORMAT_OPTIONS = [ { id: "json", label: "JSON", accept: "application/json" }, { id: "html", label: "HTML", accept: "text/html" }, ]; export class ApiTester { constructor(mount, ctx) { this.mount = mount; this.ctx = ctx; try { this.config = JSON.parse(mount.dataset.config || "{}"); } catch { return; } this.inputs = []; this.authType = "apikey"; this.activeLang = "curl"; this.negotiation = this.config.negotiation || "json"; this.responseFormat = "json"; this.build(); this.renderSnippets(); this.renderExpected(); } negotiates() { return this.negotiation === "negotiable" || this.negotiation === "ajax"; } el(tag, props = {}, children = []) { const node = document.createElement(tag); for (const [key, value] of Object.entries(props)) { if (key === "class") node.className = value; else if (key === "text") node.textContent = value; else if (key === "html") node.innerHTML = value; else if (value === true) node.setAttribute(key, ""); else if (value !== false && value !== null) node.setAttribute(key, value); } for (const child of children) { if (child) node.appendChild(child); } return node; } build() { const root = this.el("div", { class: "api-tester" }); if (this.config.params.length) root.appendChild(this.buildParams()); if (this.requiresAuth()) root.appendChild(this.buildAuthPicker()); root.appendChild(this.buildFormatPicker()); root.appendChild(this.buildCode()); const notes = this.buildNotes(); if (notes) root.appendChild(notes); root.appendChild(this.buildActions()); root.appendChild(this.buildResponse()); this.mount.appendChild(root); } buildFormatPicker() { const picker = this.el("div", { class: "format-picker" }); picker.appendChild(this.el("span", { class: "format-label", text: "Response format" })); if (!this.negotiates()) { const text = this.negotiation === "none" ? this.nonBodyNote() : "Always returns JSON."; picker.appendChild(this.el("span", { class: "format-fixed", text: this.negotiation === "none" ? text : "JSON" })); if (this.negotiation !== "none") { picker.appendChild(this.el("span", { class: "format-note", text: text })); } return picker; } const group = this.el("div", { class: "format-options" }); this.formatButtons = {}; for (const option of FORMAT_OPTIONS) { const btn = this.el("button", { type: "button", class: "format-option", text: option.label }); btn.addEventListener("click", () => this.selectFormat(option.id)); this.formatButtons[option.id] = btn; group.appendChild(btn); } picker.appendChild(group); if (this.negotiation === "ajax") { picker.appendChild(this.el("span", { class: "format-note", text: "JSON uses the X-Requested-With header; HTML shows the redirect." })); } this.selectFormat(this.responseFormat, true); return picker; } nonBodyNote() { if (this.config.id === "avatar") return "Returns an SVG image."; if (this.config.id === "gateway-passthrough") return "Proxied upstream response."; return "Redirect only."; } selectFormat(id, silent) { this.responseFormat = id; if (this.formatButtons) { for (const [fmt, btn] of Object.entries(this.formatButtons)) { btn.classList.toggle("active", fmt === id); } } if (!silent) { this.renderSnippets(); this.renderExpected(); } } acceptHeader() { const option = FORMAT_OPTIONS.find((o) => o.id === this.responseFormat); return option ? option.accept : "application/json"; } requiresAuth() { return this.config.auth !== "public"; } buildParams() { const table = this.el("div", { class: "param-table" }); for (const param of this.config.params) { const control = this.buildControl(param); this.inputs.push({ param, control }); control.addEventListener("input", () => this.renderSnippets()); control.addEventListener("change", () => this.renderSnippets()); const label = this.el("div", { class: "param-label" }, [ this.el("span", { class: "param-name", text: param.name }), param.required ? this.el("span", { class: "param-required", text: "*" }) : null, this.el("span", { class: "param-loc param-loc-" + param.location, text: param.location }), ]); const allowed = param.type === "enum" && param.options && param.options.length ? this.el("div", { class: "param-allowed" }, [ this.el("span", { class: "param-allowed-label", text: "Allowed: " }), this.el("span", { class: "param-allowed-values", text: param.options.join(", ") }), ]) : null; const meta = this.el("div", { class: "param-meta" }, [ control, param.description ? this.el("div", { class: "param-desc", text: param.description }) : null, allowed, ]); table.appendChild(this.el("div", { class: "param-row" }, [label, meta])); } return table; } buildControl(param) { if (param.type === "enum") { const select = this.el("select", { class: "param-input" }); for (const option of param.options || []) { const opt = this.el("option", { value: option, text: option }); if (option === param.example) opt.selected = true; select.appendChild(opt); } return select; } if (param.type === "textarea") { const area = this.el("textarea", { class: "param-input", rows: "3" }); area.value = param.example || ""; return area; } if (param.type === "file") { return this.el("input", { class: "param-input", type: "file" }); } const input = this.el("input", { class: "param-input", type: param.type === "int" ? "number" : "text", placeholder: param.example || "", }); input.value = param.example || ""; return input; } buildAuthPicker() { const picker = this.el("div", { class: "auth-picker" }); const select = this.el("select", { class: "param-input auth-method" }); for (const method of AUTH_METHODS) { select.appendChild(this.el("option", { value: method.id, text: method.label })); } select.value = this.authType; this.keyField = this.el("input", { class: "param-input", type: "text", placeholder: "API key" }); this.keyField.value = this.ctx.apiKey || ""; this.userField = this.el("input", { class: "param-input", type: "text", placeholder: "username or email" }); this.userField.value = this.ctx.username || ""; this.passField = this.el("input", { class: "param-input", type: "password", placeholder: "password" }); this.keyRow = this.el("div", { class: "auth-field" }, [this.el("label", { text: "Key" }), this.keyField]); this.basicRow = this.el("div", { class: "auth-field auth-basic" }, [ this.el("label", { text: "Identity" }), this.userField, this.el("label", { text: "Password" }), this.passField, ]); select.addEventListener("change", () => { this.authType = select.value; this.syncAuthRows(); this.renderSnippets(); }); for (const f of [this.keyField, this.userField, this.passField]) { f.addEventListener("input", () => this.renderSnippets()); } picker.appendChild(this.el("div", { class: "auth-field" }, [this.el("label", { text: "Auth" }), select])); picker.appendChild(this.keyRow); picker.appendChild(this.basicRow); this.syncAuthRows(); return picker; } syncAuthRows() { if (!this.keyRow) return; const showKey = this.authType === "apikey" || this.authType === "bearer"; this.keyRow.hidden = !showKey; this.basicRow.hidden = this.authType !== "basic"; } buildCode() { const wrap = this.el("div", { class: "code-block" }); const tabs = this.el("div", { class: "code-tabs" }); this.tabButtons = {}; for (const lang of LANG_TABS) { const btn = this.el("button", { type: "button", class: "code-tab", text: lang.label }); btn.addEventListener("click", () => this.selectLang(lang.id)); this.tabButtons[lang.id] = btn; tabs.appendChild(btn); } this.panel = this.el("pre", { class: "code-panel" }); this.codeEl = this.el("code"); this.panel.appendChild(this.codeEl); wrap.appendChild(tabs); wrap.appendChild(this.panel); this.selectLang(this.activeLang, true); return wrap; } selectLang(id, silent) { this.activeLang = id; for (const [lang, btn] of Object.entries(this.tabButtons)) { btn.classList.toggle("active", lang === id); } if (!silent) this.renderSnippets(); } buildNotes() { if (!this.config.notes.length) return null; const wrap = this.el("div", { class: "endpoint-notes" }); contentRenderer.ready.then(() => { wrap.innerHTML = contentRenderer.render(this.config.notes.join("\n\n")); }); return wrap; } buildActions() { const bar = this.el("div", { class: "try-panel" }); if (this.config.interactive === false) { bar.appendChild(this.el("span", { class: "try-note", text: "Example only - run this from your own client." })); return bar; } if (this.requiresAuth() && !this.ctx.loggedIn) { const note = this.el("span", { class: "try-note" }); note.innerHTML = 'Sign in to run this. Log in'; bar.appendChild(note); return bar; } if (this.config.auth === "admin" && !this.ctx.isAdmin) { bar.appendChild(this.el("span", { class: "try-note", text: "Administrator account required to run this." })); return bar; } this.sendBtn = this.el("button", { type: "button", class: "btn btn-primary try-send", text: "Send request" }); this.sendBtn.addEventListener("click", () => this.send()); bar.appendChild(this.sendBtn); return bar; } buildResponse() { const wrap = this.el("div", { class: "response-viewer" }); const tabs = this.el("div", { class: "response-tabs" }); this.responseTabs = {}; for (const tab of [{ id: "expected", label: "Expected" }, { id: "live", label: "Live response" }]) { const btn = this.el("button", { type: "button", class: "code-tab", text: tab.label }); btn.addEventListener("click", () => this.selectResponseTab(tab.id)); this.responseTabs[tab.id] = btn; tabs.appendChild(btn); } this.expectedEl = this.el("div", { class: "response-pane" }); this.liveEl = this.el("div", { class: "response-pane" }); const placeholder = this.config.interactive === false ? "Example only - run this from your own client." : "Run the request to see the live response."; this.liveEl.appendChild(this.el("p", { class: "response-placeholder", text: placeholder })); wrap.appendChild(tabs); wrap.appendChild(this.expectedEl); wrap.appendChild(this.liveEl); this.selectResponseTab("expected"); return wrap; } selectResponseTab(id) { for (const [name, btn] of Object.entries(this.responseTabs)) { btn.classList.toggle("active", name === id); } this.expectedEl.classList.toggle("active", id === "expected"); this.liveEl.classList.toggle("active", id === "live"); } renderExpected() { if (!this.expectedEl) return; this.expectedEl.innerHTML = ""; if (this.responseFormat === "html" && this.negotiation === "negotiable") { const redirect = this.config.sample_response && this.config.sample_response.redirect; const text = redirect ? `Returns a 302 redirect to ${redirect} (Location header). Browsers follow it; the page itself is HTML.` : "Returns the rendered HTML page (Content-Type: text/html)."; this.expectedEl.appendChild(this.el("p", { class: "response-note", text: text })); return; } if (this.negotiation === "none") { this.expectedEl.appendChild(this.el("p", { class: "response-note", text: this.nonBodyNote() })); return; } const sample = this.config.sample_response; if (sample === null || sample === undefined) { this.expectedEl.appendChild(this.el("p", { class: "response-note", text: "No documented response body." })); return; } const caption = this.negotiation === "ajax" ? "Flat JSON shape (sent with X-Requested-With: fetch):" : "Modeled JSON response:"; this.expectedEl.appendChild(this.el("p", { class: "response-note", text: caption })); const pre = this.el("pre", { class: "response-body" }); pre.appendChild(this.el("code", { class: "language-json", text: JSON.stringify(sample, null, 2) })); this.expectedEl.appendChild(pre); CodeBlock.enhance(pre, { lineNumbers: false }); } values() { const out = { path: {}, query: {}, form: {}, json: {}, file: null }; for (const { param, control } of this.inputs) { if (param.type === "file") { out.file = control.files && control.files[0] ? control.files[0] : null; continue; } const value = control.value; if (value === "" && !param.required) continue; out[param.location][param.name] = value; } return out; } auth() { return { type: this.authType, key: this.keyField ? this.keyField.value : "", username: this.userField ? this.userField.value : "", password: this.passField ? this.passField.value : "", }; } buildPath(values) { let path = this.config.path; for (const [name, value] of Object.entries(values.path)) { path = path.replace("{" + name + "}", encodeURIComponent(value)); } return path; } queryString(values) { const entries = Object.entries(values.query); if (!entries.length) return ""; return "?" + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); } jsonBody(values) { const body = {}; for (const [name, value] of Object.entries(values.json)) { try { body[name] = JSON.parse(value); } catch { body[name] = value; } } return body; } headerPairs(auth, includeContentType) { const headers = []; if (auth.type === "apikey" && auth.key) headers.push(["X-API-KEY", auth.key]); if (auth.type === "bearer" && auth.key) headers.push(["Authorization", "Bearer " + auth.key]); if (auth.type === "basic") headers.push(["Authorization", "Basic "]); if (this.negotiation !== "none") headers.push(["Accept", this.acceptHeader()]); if (this.config.ajax && this.responseFormat === "json") headers.push(["X-Requested-With", "fetch"]); if (includeContentType && this.config.encoding === "json") headers.push(["Content-Type", "application/json"]); if (includeContentType && this.config.encoding === "form") headers.push(["Content-Type", "application/x-www-form-urlencoded"]); return headers; } renderSnippets() { if (!this.codeEl) return; const values = this.values(); const auth = this.auth(); const builders = { curl: () => this.curl(values, auth), javascript: () => this.js(values, auth), python: () => this.python(values, auth) }; this.codeEl.textContent = builders[this.activeLang](); const lang = LANG_TABS.find((l) => l.id === this.activeLang); this.codeEl.className = "language-" + lang.hl; CodeBlock.refresh(this.panel); } shellQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'"; } curl(values, auth) { const url = this.ctx.base + this.buildPath(values) + this.queryString(values); const lines = ["curl"]; if (this.config.method !== "GET") lines.push("-X " + this.config.method); lines.push(this.shellQuote(url)); if (auth.type === "basic") { lines.push("-u " + this.shellQuote(`${auth.username}:${auth.password || "PASSWORD"}`)); } for (const [name, value] of this.headerPairs(auth, false)) { if (name === "Authorization" && auth.type === "basic") continue; lines.push("-H " + this.shellQuote(`${name}: ${value}`)); } if (this.config.encoding === "form") { for (const [name, value] of Object.entries(values.form)) { lines.push("-d " + this.shellQuote(`${name}=${value}`)); } } else if (this.config.encoding === "json") { lines.push("-H " + this.shellQuote("Content-Type: application/json")); lines.push("-d " + this.shellQuote(JSON.stringify(this.jsonBody(values)))); } else if (this.config.encoding === "multipart") { lines.push("-F " + this.shellQuote("file=@/path/to/file")); } return lines.join(" \\\n "); } js(values, auth) { const url = this.ctx.base + this.buildPath(values) + this.queryString(values); const headers = this.headerPairs(auth, true).filter(([n]) => !(auth.type === "basic" && n === "Authorization")); const lines = []; const opts = [" method: " + JSON.stringify(this.config.method)]; const headerObj = headers.map(([n, v]) => ` ${JSON.stringify(n)}: ${JSON.stringify(v)}`); if (auth.type === "basic") { lines.push(`const credentials = btoa(${JSON.stringify(auth.username + ":" + (auth.password || "PASSWORD"))});`); headerObj.push(' "Authorization": "Basic " + credentials'); } if (headerObj.length) opts.push(" headers: {\n" + headerObj.join(",\n") + "\n }"); if (this.config.encoding === "form") { opts.push(" body: new URLSearchParams(" + JSON.stringify(values.form, null, 2).replace(/\n/g, "\n ") + ")"); } else if (this.config.encoding === "json") { opts.push(" body: JSON.stringify(" + JSON.stringify(this.jsonBody(values), null, 2).replace(/\n/g, "\n ") + ")"); } else if (this.config.encoding === "multipart") { lines.push("const form = new FormData();"); lines.push('form.append("file", fileInput.files[0]);'); opts.push(" body: form"); } lines.push(`const response = await fetch(${JSON.stringify(url)}, {\n${opts.join(",\n")}\n});`); lines.push("const data = await response.json();"); lines.push("console.log(data);"); return lines.join("\n"); } python(values, auth) { const url = this.ctx.base + this.buildPath(values); const args = [JSON.stringify(url)]; if (Object.keys(values.query).length) args.push("params=" + this.pyDict(values.query)); if (this.config.encoding === "form") args.push("data=" + this.pyDict(values.form)); if (this.config.encoding === "json") args.push("json=" + this.pyJson(this.jsonBody(values))); if (this.config.encoding === "multipart") args.push('files={"file": open("/path/to/file", "rb")}'); const headers = this.headerPairs(auth, false).filter(([n]) => !(auth.type === "basic" && n === "Authorization")); if (headers.length) args.push("headers=" + this.pyDict(Object.fromEntries(headers))); if (auth.type === "basic") args.push(`auth=(${JSON.stringify(auth.username)}, ${JSON.stringify(auth.password || "PASSWORD")})`); const method = this.config.method.toLowerCase(); const lines = ["import requests", "", `response = requests.${method}(\n ${args.join(",\n ")}\n)`, "print(response.json())"]; return lines.join("\n"); } pyDict(obj) { const entries = Object.entries(obj).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`); if (!entries.length) return "{}"; return "{\n" + entries.join(",\n") + "\n }"; } pyJson(obj) { return JSON.stringify(obj, null, 4).replace(/\n/g, "\n "); } async send() { if (this.config.destructive || this.config.method === "DELETE") { const ok = await window.app.dialog.confirm({ title: "Run request", message: "This performs a real action on your account. Continue?", confirmLabel: "Run", danger: true, }); if (!ok) return; } const values = this.values(); const auth = this.auth(); const url = this.ctx.base + this.buildPath(values) + this.queryString(values); const headers = {}; for (const [name, value] of this.headerPairs(auth, false)) { if (auth.type === "basic" && name === "Authorization") continue; headers[name] = value; } if (auth.type === "basic") headers["Authorization"] = "Basic " + btoa(`${auth.username}:${auth.password}`); const options = { method: this.config.method, headers }; if (this.config.encoding === "form") { options.body = new URLSearchParams(values.form); headers["Content-Type"] = "application/x-www-form-urlencoded"; } else if (this.config.encoding === "json") { options.body = JSON.stringify(this.jsonBody(values)); headers["Content-Type"] = "application/json"; } else if (this.config.encoding === "multipart") { if (!values.file) { this.showError("Choose a file first."); return; } const form = new FormData(); form.append("file", values.file); options.body = form; } this.sendBtn.disabled = true; const started = performance.now(); try { const response = await fetch(url, options); const ms = Math.round(performance.now() - started); await this.renderResponse(response, ms); } catch (error) { this.showError(error.message || "Network error"); } finally { this.sendBtn.disabled = false; } } async renderResponse(response, ms) { const contentType = response.headers.get("content-type") || ""; this.liveEl.innerHTML = ""; this.selectResponseTab("live"); const cls = "response-" + Math.floor(response.status / 100) + "xx"; const status = this.el("span", { class: "response-status " + cls, text: `${response.status} ${response.statusText}` }); const meta = this.el("div", { class: "response-meta" }, [ status, this.el("span", { text: `${ms} ms` }), this.el("span", { text: contentType.split(";")[0] || "unknown" }), ]); this.liveEl.appendChild(meta); if (response.redirected) { this.liveEl.appendChild(this.el("p", { class: "response-redirect", text: "Redirected to " + response.url })); } if (contentType.includes("application/json")) { const data = await response.json(); const pre = this.el("pre", { class: "response-body" }); const code = this.el("code", { class: "language-json", text: JSON.stringify(data, null, 2) }); pre.appendChild(code); this.liveEl.appendChild(pre); CodeBlock.enhance(pre, { lineNumbers: false }); return; } const text = await response.text(); const note = contentType.includes("html") ? `HTML response (${text.length} bytes)` : `Response (${text.length} bytes)`; this.liveEl.appendChild(this.el("p", { class: "response-redirect", text: note })); const pre = this.el("pre", { class: "response-body" }); pre.appendChild(this.el("code", { text: text.slice(0, 2000) })); this.liveEl.appendChild(pre); CodeBlock.enhance(pre, { highlight: false, lineNumbers: false }); } showError(message) { this.liveEl.innerHTML = ""; this.selectResponseTab("live"); this.liveEl.appendChild(this.el("div", { class: "response-meta" }, [ this.el("span", { class: "response-status response-5xx", text: "Error" }), ])); this.liveEl.appendChild(this.el("p", { class: "response-redirect", text: message })); } } window.ApiTester = ApiTester;