diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts
index 365fb1e..8121a19 100644
--- a/e2e/tests/endpoint-screen.spec.ts
+++ b/e2e/tests/endpoint-screen.spec.ts
@@ -149,6 +149,72 @@ test.describe("Endpoint screen", () => {
expect(tagCount).toBeGreaterThan(0);
});
+ test("renders multipart/form-data fields as a parsed JSON array", async ({
+ page,
+ request,
+ }) => {
+ await request.post(endpointUrl, {
+ multipart: { firstName: "Ada", role: "engineer" },
+ });
+ const body = page.locator('[data-test="request-body"]').first();
+ const text = await body.locator("pre").innerText();
+ expect(text).toContain('"name": "firstName"');
+ expect(text).toContain('"value": "Ada"');
+ expect(text).toContain('"name": "role"');
+ expect(text).toContain('"value": "engineer"');
+ const tokenCount = await body.locator("pre span.hljs-string").count();
+ expect(tokenCount).toBeGreaterThan(0);
+ });
+
+ test("renders multipart file parts as metadata only, never file content", async ({
+ page,
+ request,
+ }) => {
+ await request.post(endpointUrl, {
+ multipart: {
+ avatar: {
+ name: "avatar.png",
+ mimeType: "image/png",
+ buffer: Buffer.from("fake-png-bytes"),
+ },
+ },
+ });
+ const body = page.locator('[data-test="request-body"]').first();
+ const text = await body.locator("pre").innerText();
+ expect(text).toContain('"filename": "avatar.png"');
+ expect(text).toContain('"contentType": "image/png"');
+ expect(text).toMatch(/"size":\s*\d+/);
+ expect(text).not.toContain("fake-png-bytes");
+ });
+
+ test("keeps repeated multipart field names as separate array entries", async ({
+ page,
+ request,
+ }) => {
+ const formData = new FormData();
+ formData.append("tag", "red");
+ formData.append("tag", "blue");
+ await request.post(endpointUrl, { multipart: formData });
+ const body = page.locator('[data-test="request-body"]').first();
+ const text = await body.locator("pre").innerText();
+ const tagValues = [
+ ...text.matchAll(/"name": "tag",\s*\n\s*"value": "(\w+)"/g),
+ ].map((m) => m[1]);
+ expect(tagValues).toEqual(["red", "blue"]);
+ });
+
+ test("falls back to raw display when a multipart body has no boundary", async ({
+ page,
+ request,
+ }) => {
+ await post(request, endpointUrl, {
+ data: "not-actually-parseable-multipart",
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ const body = page.locator('[data-test="request-body"]').first();
+ await expect(body).toContainText("not-actually-parseable-multipart");
+ });
+
test("body copy button writes raw body to clipboard", async ({
page,
request,
diff --git a/public/index.js b/public/index.js
index 40e39c7..24d0a3d 100644
--- a/public/index.js
+++ b/public/index.js
@@ -24,39 +24,6 @@ window.formatTimeAgo = function (date) {
return "";
};
-window.htmlEscape = function (s) {
- return String(s)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-};
-
-window.renderBody = function (body, headers) {
- if (body == null || body === "") return "";
- // Best-effort JSON pretty + highlight.
- try {
- const pretty = JSON.stringify(JSON.parse(body), null, 2);
- if (window.hljs && window.hljs.getLanguage("json")) {
- return window.hljs.highlight(pretty, { language: "json" }).value;
- }
- return window.htmlEscape(pretty);
- } catch (_) {
- // not JSON
- }
- // Heuristic: looks like XML/HTML if it starts with '<'
- const trimmed = body.trimStart();
- if (
- trimmed.startsWith("<") &&
- window.hljs &&
- window.hljs.getLanguage("xml")
- ) {
- return window.hljs.highlight(body, { language: "xml" }).value;
- }
- return window.htmlEscape(body);
-};
-
/* Clipboard helper */
window.copyToClipboard = async function (text) {
diff --git a/public/render-body.js b/public/render-body.js
new file mode 100644
index 0000000..b86187e
--- /dev/null
+++ b/public/render-body.js
@@ -0,0 +1,143 @@
+/* Body rendering: best-effort content-type-aware display for a captured
+ request body (JSON pretty-print, multipart/form-data part list, XML
+ highlighting, or escaped raw text). Loaded on every page. */
+
+function htmlEscape(s) {
+ return String(s)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function highlightPrettyJSON(value) {
+ const pretty = JSON.stringify(value, null, 2);
+ if (window.hljs && window.hljs.getLanguage("json")) {
+ return window.hljs.highlight(pretty, { language: "json" }).value;
+ }
+ return htmlEscape(pretty);
+}
+
+/* Case-insensitive lookup into a headers object whose values are either a
+ scalar string or a string[] (see the flattening in application.go). */
+function headerValue(headers, name) {
+ if (!headers) return undefined;
+ const key = Object.keys(headers).find(
+ (k) => k.toLowerCase() === name.toLowerCase(),
+ );
+ if (key === undefined) return undefined;
+ const value = headers[key];
+ return Array.isArray(value) ? value[0] : value;
+}
+
+/* Extracts the `boundary` parameter from a multipart/form-data Content-Type
+ header value (quoted or unquoted). Returns null if the header isn't
+ multipart/form-data or carries no boundary. */
+function multipartBoundary(contentType) {
+ if (!contentType) return null;
+ const [mime, ...params] = contentType.split(";").map((s) => s.trim());
+ if (mime.toLowerCase() !== "multipart/form-data") return null;
+ for (const param of params) {
+ const eq = param.indexOf("=");
+ if (eq <= 0) continue;
+ if (param.slice(0, eq).trim().toLowerCase() !== "boundary") continue;
+ let value = param.slice(eq + 1).trim();
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
+ value = value.slice(1, -1);
+ }
+ return value || null;
+ }
+ return null;
+}
+
+/* Best-effort parse of a raw multipart/form-data body into an ordered array
+ of parts: {name, value} for fields, {name, filename, contentType, size}
+ for file parts (the file content itself is never shown). Returns null on
+ anything that doesn't look like well-formed multipart, so the caller can
+ fall through to the next rendering strategy. */
+function parseMultipart(body, boundary) {
+ const segments = body.split("--" + boundary);
+ // segments[0] is the preamble before the first delimiter, and the last
+ // segment is the epilogue after the closing "--boundary--"; both are
+ // discarded rather than treated as parts.
+ if (segments.length < 3) return null;
+
+ const parts = [];
+ for (let i = 1; i < segments.length - 1; i++) {
+ let segment = segments[i];
+ if (segment.startsWith("\r\n")) segment = segment.slice(2);
+ else if (segment.startsWith("\n")) segment = segment.slice(1);
+ if (segment.endsWith("\r\n")) segment = segment.slice(0, -2);
+ else if (segment.endsWith("\n")) segment = segment.slice(0, -1);
+
+ const headerEnd = segment.search(/\r?\n\r?\n/);
+ if (headerEnd === -1) return null;
+ const headerLines = segment.slice(0, headerEnd).split(/\r?\n/);
+ const contentMatch = segment
+ .slice(headerEnd)
+ .match(/^\r?\n\r?\n([\s\S]*)$/);
+ const content = contentMatch ? contentMatch[1] : "";
+
+ const disposition = headerLines.find((line) =>
+ /^content-disposition\s*:/i.test(line),
+ );
+ if (!disposition) return null;
+
+ const nameMatch = disposition.match(/name="([^"]*)"/i);
+ if (!nameMatch) return null;
+ const name = nameMatch[1];
+
+ const filenameMatch = disposition.match(/filename="([^"]*)"/i);
+ if (!filenameMatch) {
+ parts.push({ name, value: content });
+ continue;
+ }
+
+ const contentTypeLine = headerLines.find((line) =>
+ /^content-type\s*:/i.test(line),
+ );
+ const contentType = contentTypeLine
+ ? contentTypeLine.split(":").slice(1).join(":").trim()
+ : "application/octet-stream";
+ parts.push({
+ name,
+ filename: filenameMatch[1],
+ contentType,
+ // Byte length of the captured body, which httphq stores as a Go
+ // string — encoding/json replaces invalid UTF-8 with U+FFFD on
+ // marshal, so for genuinely binary uploads this can differ from the
+ // true original file size. See database.Request.Body.
+ size: new TextEncoder().encode(content).length,
+ });
+ }
+ return parts;
+}
+
+window.renderBody = function (body, headers) {
+ if (body == null || body === "") return "";
+
+ const boundary = multipartBoundary(headerValue(headers, "Content-Type"));
+ if (boundary) {
+ const parts = parseMultipart(body, boundary);
+ if (parts !== null) return highlightPrettyJSON(parts);
+ // Malformed multipart body — fall through to the generic paths below.
+ }
+
+ // Best-effort JSON pretty + highlight.
+ try {
+ return highlightPrettyJSON(JSON.parse(body));
+ } catch (_) {
+ // not JSON
+ }
+ // Heuristic: looks like XML/HTML if it starts with '<'
+ const trimmed = body.trimStart();
+ if (
+ trimmed.startsWith("<") &&
+ window.hljs &&
+ window.hljs.getLanguage("xml")
+ ) {
+ return window.hljs.highlight(body, { language: "xml" }).value;
+ }
+ return htmlEscape(body);
+};
diff --git a/src/views/layouts/main.html b/src/views/layouts/main.html
index 793e325..23f2307 100644
--- a/src/views/layouts/main.html
+++ b/src/views/layouts/main.html
@@ -36,6 +36,7 @@
loads, because Alpine v3 schedules its first alpine:init dispatch via
queueMicrotask at the end of its own script execution. -->
+