Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions e2e/tests/endpoint-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 0 additions & 33 deletions public/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,39 +24,6 @@ window.formatTimeAgo = function (date) {
return "";
};

window.htmlEscape = function (s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
};

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) {
Expand Down
143 changes: 143 additions & 0 deletions public/render-body.js
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

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);
};
1 change: 1 addition & 0 deletions src/views/layouts/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
loads, because Alpine v3 schedules its first alpine:init dispatch via
queueMicrotask at the end of its own script execution. -->
<script defer src="/index.js"></script>
<script defer src="/render-body.js"></script>
<script defer src="/endpoint.js"></script>

<!-- Alpine.js (loaded last so app scripts can subscribe first). -->
Expand Down
Loading