Newer
Older
smart-home-server / rest_api_debug_tool / static / index.html
<!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&#10;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&#10;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>