<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>REST API Tester</title>
<!-- Bootstrap CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- highlight.js CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css">
<style>
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.response_box { max-width: 100%; max-height: 520px; overflow: auto; }
.small_hint { font-size: .85rem; color: #6c757d; }
textarea { min-height: 140px; }
textarea#body {
height: 400px;
}
</style>
</head>
<body class="bg-light">
<div class="container py-4">
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<h4 class="mb-1">REST API Tester</h4>
<div class="small_hint">POST JSON / обычные URL запросы, заголовки, параметры, просмотр ответа.</div>
</div>
<div class="d-flex gap-2">
<button id="btn_send" class="btn btn-primary">Send</button>
<button id="btn_clear" class="btn btn-outline-secondary">Clear</button>
</div>
</div>
<hr class="my-3">
<div class="row g-3">
<div class="col-12 col-lg-8">
<label class="form-label">URL</label>
<input id="url" class="form-control mono" placeholder="https://example.com/api/v1/test" value="">
<div class="small_hint mt-1">
Примечание: если используешь локальные домены/HTTP, может упереться в CORS. В таком случае запускай это как расширение/через локальный прокси или делай запросы с backend-прокси.
</div>
</div>
<div class="col-6 col-lg-2">
<label class="form-label">Method</label>
<select id="method" class="form-select">
<option>GET</option>
<option selected>POST</option>
<option>PUT</option>
<option>PATCH</option>
<option>DELETE</option>
</select>
</div>
<div class="col-6 col-lg-2">
<label class="form-label">Body type</label>
<select id="body_type" class="form-select">
<option value="none">None</option>
<option value="json" selected>JSON</option>
<option value="form">Form URL Encoded</option>
<option value="raw">Raw text</option>
</select>
</div>
<div class="col-12">
<div class="row g-3">
<div class="col-12 col-xl-6">
<label class="form-label">Query params (key=value, по одной строке)</label>
<textarea id="query_params" class="form-control mono" placeholder="page=1 limit=50"></textarea>
</div>
<div class="col-12 col-xl-6">
<label class="form-label">Headers (key: value, по одной строке)</label>
<textarea id="headers" class="form-control mono" placeholder="Authorization: Bearer xxx X-Test: 123"></textarea>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label">Body</label>
<textarea id="body" class="form-control mono" placeholder='{"hello":"world"}'></textarea>
<div class="d-flex gap-2 mt-2 flex-wrap">
<button id="btn_pretty" class="btn btn-sm btn-outline-primary">Pretty JSON</button>
<button id="btn_minify" class="btn btn-sm btn-outline-primary">Minify JSON</button>
<button id="btn_example" class="btn btn-sm btn-outline-secondary">Example</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Response -->
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5 class="mb-0">Response</h5>
<div class="d-flex gap-2 align-items-center flex-wrap">
<span id="status_badge" class="badge text-bg-secondary">—</span>
<span id="time_badge" class="badge text-bg-light text-dark">—</span>
<span id="size_badge" class="badge text-bg-light text-dark">—</span>
<button id="btn_copy" class="btn btn-sm btn-outline-secondary">Copy</button>
</div>
</div>
<hr class="my-3">
<div class="row g-3">
<div class="col-12 col-xl-4">
<label class="form-label">Response headers</label>
<div id="resp_headers" class="border rounded p-2 bg-white mono response_box"></div>
</div>
<div class="col-12 col-xl-8">
<label class="form-label">Response body</label>
<!-- JSON pretty + highlight -->
<pre id="resp_pre" class="border rounded p-2 bg-white response_box mb-0 d-none"><code id="resp_code" class="language-json mono"></code></pre>
<!-- plain text fallback -->
<div id="resp_text" class="border rounded p-2 bg-white mono response_box d-none"></div>
<!-- error -->
<div id="resp_error" class="alert alert-danger mt-2 d-none mb-0"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- highlight.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/languages/json.min.js"></script>
<script>
// -------------------- helpers --------------------
function parse_key_value_lines(text) {
// For query params: key=value
// For headers: key: value
const lines = (text || "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
return lines;
}
function build_query_string(lines) {
const params = new URLSearchParams();
for (const line of lines) {
const idx = line.indexOf("=");
if (idx === -1) continue;
const k = line.slice(0, idx).trim();
const v = line.slice(idx + 1).trim();
if (!k) continue;
params.append(k, v);
}
const s = params.toString();
return s ? ("?" + s) : "";
}
function parse_headers(lines) {
const h = {};
for (const line of lines) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const k = line.slice(0, idx).trim();
const v = line.slice(idx + 1).trim();
if (!k) continue;
h[k] = v;
}
return h;
}
function safe_json_parse(str) {
try { return { ok: true, value: JSON.parse(str) }; }
catch (e) { return { ok: false, error: e }; }
}
function pretty_json(str) {
const r = safe_json_parse(str);
if (!r.ok) throw r.error;
return JSON.stringify(r.value, null, 2);
}
function minify_json(str) {
const r = safe_json_parse(str);
if (!r.ok) throw r.error;
return JSON.stringify(r.value);
}
function set_badge_status(status) {
const el = document.getElementById("status_badge");
el.textContent = String(status);
el.className = "badge " + (status >= 200 && status < 300 ? "text-bg-success" :
status >= 400 ? "text-bg-danger" :
status >= 300 ? "text-bg-warning" : "text-bg-secondary");
}
function set_badge_time(ms) {
const el = document.getElementById("time_badge");
el.textContent = ms.toFixed(0) + " ms";
}
function set_badge_size(bytes) {
const el = document.getElementById("size_badge");
if (bytes == null) { el.textContent = "—"; return; }
const kb = bytes / 1024;
el.textContent = kb >= 1024 ? (kb/1024).toFixed(2) + " MB" : kb.toFixed(2) + " KB";
}
function show_error(msg) {
const el = document.getElementById("resp_error");
el.textContent = msg;
el.classList.remove("d-none");
}
function hide_error() {
const el = document.getElementById("resp_error");
el.classList.add("d-none");
el.textContent = "";
}
function show_json(code_text) {
const pre = document.getElementById("resp_pre");
const code = document.getElementById("resp_code");
const txt = document.getElementById("resp_text");
pre.classList.remove("d-none");
txt.classList.add("d-none");
code.textContent = code_text;
hljs.highlightElement(code);
}
function show_text(text) {
const pre = document.getElementById("resp_pre");
const txt = document.getElementById("resp_text");
pre.classList.add("d-none");
txt.classList.remove("d-none");
txt.innerHTML = text;
}
function set_response_headers_from_object(obj) {
const el = document.getElementById("resp_headers");
const lines = [];
for (const [k, v] of Object.entries(obj || {})) {
lines.push(k + ": " + v);
}
el.textContent = lines.join("\n") || "—";
}
function guess_is_json(content_type, body_text) {
if (content_type && content_type.toLowerCase().includes("application/json")) return true;
const t = (body_text || "").trim();
if (!t) return false;
return (t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"));
}
// -------------------- main (via Flask proxy) --------------------
async function send_request() {
hide_error();
const url_base = document.getElementById("url").value.trim();
const method = document.getElementById("method").value.trim().toUpperCase();
const body_type = document.getElementById("body_type").value;
if (!url_base) {
show_error("Укажи URL.");
return;
}
const query_lines = parse_key_value_lines(document.getElementById("query_params").value);
const headers_lines = parse_key_value_lines(document.getElementById("headers").value);
const query_string = build_query_string(query_lines);
const full_url = url_base + query_string;
const headers = parse_headers(headers_lines);
let body = undefined;
const raw_body = document.getElementById("body").value;
// валидируем JSON заранее (удобнее чем ловить на сервере)
if (body_type === "json") {
if (raw_body.trim().length > 0) {
const parsed = safe_json_parse(raw_body);
if (!parsed.ok) {
show_error("Body не является валидным JSON: " + parsed.error.message);
return;
}
body = parsed.value; // отправим объектом, прокси положит в requests.json
} else {
body = ""; // пустое тело
}
} else if (body_type === "form") {
// строка "a=1\nb=2" или как есть
body = raw_body;
} else if (body_type === "raw") {
body = raw_body;
} else {
body = "";
}
const proxy_payload = {
url: full_url,
method,
headers,
body_type,
body: (method === "GET" || method === "HEAD") ? "" : body
};
const t0 = performance.now();
try {
const resp = await fetch("/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(proxy_payload)
});
const t1 = performance.now();
const proxy_json = await resp.json().catch(() => ({}));
if (!resp.ok) {
set_badge_status("ERR");
set_badge_time(t1 - t0);
set_badge_size(null);
set_response_headers_from_object({});
show_text("");
show_error(proxy_json.error || "Proxy error");
return;
}
// upstream status
set_badge_status(proxy_json.status);
set_badge_time(t1 - t0);
// upstream headers
set_response_headers_from_object(proxy_json.headers || {});
const text = (proxy_json.body ?? "").toString();
const content_type = (proxy_json.content_type ?? "").toString();
// size hint
try {
const bytes = new TextEncoder().encode(text).length;
set_badge_size(bytes);
} catch {
set_badge_size(null);
}
if (guess_is_json(content_type, text)) {
const parsed = safe_json_parse(text);
if (parsed.ok) show_json(JSON.stringify(parsed.value, null, 2));
else show_text(text); // похоже на json, но не парсится
} else {
show_text(text);
}
} catch (e) {
const t1 = performance.now();
set_badge_status("ERR");
set_badge_time(t1 - t0);
set_badge_size(null);
set_response_headers_from_object({});
show_text("");
show_error("Fetch error: " + (e && e.message ? e.message : String(e)));
}
}
// -------------------- UI actions --------------------
document.getElementById("btn_send").addEventListener("click", send_request);
document.getElementById("btn_clear").addEventListener("click", () => {
document.getElementById("query_params").value = "";
document.getElementById("headers").value = "";
document.getElementById("body").value = "";
document.getElementById("resp_headers").textContent = "—";
show_text("");
set_badge_status("—");
document.getElementById("time_badge").textContent = "—";
document.getElementById("size_badge").textContent = "—";
hide_error();
});
document.getElementById("btn_pretty").addEventListener("click", () => {
try {
const body = document.getElementById("body").value;
document.getElementById("body").value = pretty_json(body);
} catch (e) {
show_error("Pretty JSON error: " + e.message);
}
});
document.getElementById("btn_minify").addEventListener("click", () => {
try {
const body = document.getElementById("body").value;
document.getElementById("body").value = minify_json(body);
} catch (e) {
show_error("Minify JSON error: " + e.message);
}
});
document.getElementById("btn_example").addEventListener("click", () => {
document.getElementById("url").value = "http://localhost:8000/api/v1/test";
document.getElementById("method").value = "POST";
document.getElementById("body_type").value = "json";
document.getElementById("headers").value = "Authorization: Bearer YOUR_TOKEN";
document.getElementById("query_params").value = "debug=1";
document.getElementById("body").value = JSON.stringify({ action: "ping", value: 123 }, null, 2);
});
document.getElementById("btn_copy").addEventListener("click", async () => {
const pre_hidden = document.getElementById("resp_pre").classList.contains("d-none");
const txt_hidden = document.getElementById("resp_text").classList.contains("d-none");
let data = "";
if (!pre_hidden) data = document.getElementById("resp_code").textContent || "";
else if (!txt_hidden) data = document.getElementById("resp_text").textContent || "";
try {
await navigator.clipboard.writeText(data);
} catch {
const ta = document.createElement("textarea");
ta.value = data;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
});
// disable body for GET/HEAD (оставим как было)
document.getElementById("method").addEventListener("change", () => {
const method = document.getElementById("method").value.toUpperCase();
const body_type = document.getElementById("body_type");
const body = document.getElementById("body");
const disable = (method === "GET" || method === "HEAD");
body_type.disabled = disable;
body.disabled = disable;
if (disable) body_type.value = "none";
});
</script>
</body>
</html>