forked from retoor/devplacepy
feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
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" });
|
||||
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. <a href="/auth/login">Log in</a>';
|
||||
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 <base64(identity:password)>"]);
|
||||
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") {
|
||||
if (!window.confirm("This performs a real action on your account. Continue?")) 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;
|
||||
Reference in New Issue
Block a user