diff --git a/src/lib/http.js b/src/lib/http.js new file mode 100644 index 0000000..f83eaa5 --- /dev/null +++ b/src/lib/http.js @@ -0,0 +1,103 @@ +"use strict"; + +function json(status, data, extraHeaders) { + return { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...(extraHeaders || {}), + }, + body: JSON.stringify(data), + }; +} + +function html(status, markup, extraHeaders) { + return { + status, + headers: { + "content-type": "text/html; charset=utf-8", + ...(extraHeaders || {}), + }, + body: markup, + }; +} + +function text(status, body, contentType, extraHeaders) { + return { + status, + headers: { + "content-type": contentType || "text/plain; charset=utf-8", + ...(extraHeaders || {}), + }, + body, + }; +} + +function redirect(location, extraHeaders) { + return { + status: 303, + headers: { + location, + ...(extraHeaders || {}), + }, + body: "", + }; +} + +function parseJSON(rawBody) { + if (!rawBody) { + return {}; + } + + try { + return JSON.parse(rawBody); + } catch { + throw new Error("invalid_json"); + } +} + +function parseFormUrlEncoded(rawBody) { + if (!rawBody) { + return {}; + } + + return String(rawBody) + .split("&") + .filter(Boolean) + .reduce((acc, part) => { + const idx = part.indexOf("="); + if (idx === -1) { + const key = decodeURIComponent(part.replaceAll("+", " ")); + acc[key] = ""; + return acc; + } + + const key = decodeURIComponent(part.slice(0, idx).replaceAll("+", " ")); + const value = decodeURIComponent(part.slice(idx + 1).replaceAll("+", " ")); + acc[key] = value; + return acc; + }, {}); +} + +function withQuery(path, queryObj) { + const entries = Object.entries(queryObj || {}).filter(([, v]) => v !== undefined && v !== null && v !== ""); + if (entries.length === 0) { + return path; + } + + const query = entries + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join("&"); + + return `${path}?${query}`; +} + +module.exports = { + json, + html, + text, + redirect, + parseJSON, + parseFormUrlEncoded, + withQuery, +}; diff --git a/test/http.test.js b/test/http.test.js new file mode 100644 index 0000000..284d72c --- /dev/null +++ b/test/http.test.js @@ -0,0 +1,31 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + redirect, + parseJSON, + parseFormUrlEncoded, + withQuery, +} = require("../src/lib/http"); + +test("redirect returns 303 and location", () => { + const response = redirect("/app"); + assert.equal(response.status, 303); + assert.equal(response.headers.location, "/app"); +}); + +test("parseJSON throws on invalid payload", () => { + assert.throws(() => parseJSON("{invalid}"), /invalid_json/); +}); + +test("parseFormUrlEncoded parses plus and percent encoding", () => { + const body = "title=Hello+World&body=Line%201%0ALine%202"; + const parsed = parseFormUrlEncoded(body); + assert.equal(parsed.title, "Hello World"); + assert.equal(parsed.body, "Line 1\nLine 2"); +}); + +test("withQuery appends query params", () => { + assert.equal(withQuery("/login", { returnTo: "/audio/1", flash: "done" }), "/login?returnTo=%2Faudio%2F1&flash=done"); +});