diff --git a/docs/request-bodies/README.md b/docs/request-bodies/README.md index 12cf7f1..c5daeee 100644 --- a/docs/request-bodies/README.md +++ b/docs/request-bodies/README.md @@ -21,20 +21,57 @@ await fetch("https://api.example.com/widgets", { A malformed body throws out of the decode step and reaches the caller as a network error. Decoding failures stay loud. -## Supply a decoder +## Form encoding Stripe, Rails and PHP applications take `application/x-www-form-urlencoded` and answer with JSON. -Give the resource a `decode` function and its operations read bodies that way. +`decodeForm` reads that format, including the bracketed nesting all three of them write. ```ts -const widgets = api.resource({ - name: "widget", - path: "/widgets", - decode: async (request) => - Object.fromEntries(new URLSearchParams(await request.text())), -}); +import { decodeForm, SimApi } from "@kensio/simnaril"; + +const api = new SimApi({ decode: decodeForm }); +``` + +A body of + +```text +line_items[0][price_data][unit_amount]=250&line_items[0][quantity]=1&expand[]=customer ``` +reaches the operation as + +```json +{ + "line_items": [{ "price_data": { "unit_amount": "250" }, "quantity": "1" }], + "expand": ["customer"] +} +``` + +Every leaf is a string. A form body carries no types, and guessing at them would make `quantity=1` +and `postcode=01234` disagree about what a digit is. Convert in the resource's own creation +behaviour, where the target shape is known. + +A part that is a run of digits makes an array, and an empty bracket appends to one. The digits order +the entries and do not position them, so `a[0]`, `a[5]` and `a[9]` give three elements and never a +sparse array of ten. A real encoder counts from zero, where the two readings agree. + +`decodeForm` ignores the `content-type` header. What a body claims to be and what it holds are two +facts, and choosing the decoder by hand has already settled the first. + +### What it refuses + +A real encoder emits well-formed keys. The bodies below are hostile or mistaken input, and each one +throws a `SyntaxError`, the way a malformed JSON body already does. + +| Body | Refused because | +| --------------- | ---------------------------------------------------------------------------------- | +| `name=a&name=b` | Which value wins is a guess. Losing half a request quietly is worse than stopping. | +| `a=1&a[b]=2` | `a` would have to hold a value and more keys at once | +| `a[][b]=1` | An empty bracket appends, and only the last part can | +| `a[b`, `[a]` | Brackets cannot be read out of the key | + +## Write your own + A decoder receives the `Request` and returns the input the operation sees. It can be synchronous or asynchronous, and it can return any shape at all. @@ -42,6 +79,14 @@ asynchronous, and it can return any shape at all. type RequestDecoder = (request: Request) => unknown; ``` +```ts +const widgets = api.resource({ + name: "widget", + path: "/widgets", + decode: async (request) => parseXml(await request.text()), +}); +``` + The package exports `decodeJson`, the default, for a decoder that wants to fall back to it. ## Three places to configure one @@ -55,9 +100,8 @@ const api = new SimApi({ decode: decodeForm }); const widgets = api.resource({ name: "widget", path: "/widgets", - decode: decodeForm, operations: { - update: { decode: decodeJsonPatch }, + update: { decode: decodeJson }, }, }); diff --git a/src/http/decode-form.test.ts b/src/http/decode-form.test.ts new file mode 100644 index 0000000..569856b --- /dev/null +++ b/src/http/decode-form.test.ts @@ -0,0 +1,160 @@ +import { faker } from "@faker-js/faker"; +import { + assertIdentical, + assertInstanceOf, + assertObjectEquals, + assertStringIncludes, + assertThrowsErrorAsync, + assertTrue, + assertUndefined, +} from "@kensio/smartass"; +import { describe, it } from "vitest"; + +import { decodeForm } from "../index.js"; + +describe("decoding a form-encoded body", () => { + const post = (body: string): Request => + new Request("https://api.example.test/things", { body, method: "POST" }); + + const decode = (body: string): Promise => + Promise.resolve(decodeForm(post(body))); + + const refuse = async (body: string): Promise => { + const error = await assertThrowsErrorAsync(() => decode(body)); + + assertInstanceOf(error, SyntaxError); + + return error; + }; + + it("reads flat pairs as strings", async () => { + // Given a body of ordinary name and value pairs. + const name = faker.commerce.productName(); + const body = new URLSearchParams({ name, quantity: "2" }).toString(); + + // When it is decoded. + const decoded = await decode(body); + + // Then every leaf is the string the body carried. + assertObjectEquals(decoded, { name, quantity: "2" }); + }); + + it("reads bracketed parts as nested objects", async () => { + // Given a key nesting through named parts. + const body = "price_data[product_data][name]=Card"; + + // When it is decoded. + const decoded = await decode(body); + + // Then each part is one level of object. + assertObjectEquals(decoded, { + price_data: { product_data: { name: "Card" } }, + }); + }); + + it("reads numbered parts as arrays", async () => { + // Given the shape Stripe's own encoder emits for a list. + const body = + "line_items[0][price_data][unit_amount]=250&line_items[0][quantity]=1" + + "&line_items[1][price_data][unit_amount]=695&line_items[1][quantity]=3"; + + // When it is decoded. + const decoded = await decode(body); + + // Then the numbered parts became an array in index order. + assertObjectEquals(decoded, { + line_items: [ + { price_data: { unit_amount: "250" }, quantity: "1" }, + { price_data: { unit_amount: "695" }, quantity: "3" }, + ], + }); + }); + + it("appends for an empty bracket", async () => { + // Given a key that appends instead of numbering. + const body = "expand[]=customer&expand[]=payment_intent"; + + // When it is decoded. + const decoded = await decode(body); + + // Then the values arrive in the order the body wrote them. + assertObjectEquals(decoded, { expand: ["customer", "payment_intent"] }); + }); + + it("orders by index without leaving gaps", async () => { + // Given indexes that count unevenly. + const body = "tag[9]=late&tag[0]=first&tag[5]=middle"; + + // When it is decoded. + const decoded = await decode(body); + + // Then the indexes ordered the entries and left no empty slots. + assertObjectEquals(decoded, { tag: ["first", "middle", "late"] }); + }); + + it("reads an empty body as an empty object", async () => { + // Given a request with nothing in its body. + // When it is decoded. + const decoded = await decode(""); + + // Then the operation is given an object with no keys. + assertObjectEquals(decoded, {}); + }); + + it("keeps a hostile __proto__ key as an ordinary property", async () => { + // Given a body naming the prototype. + const body = "__proto__[polluted]=yes"; + + // When it is decoded. + const decoded = await decode(body); + + // Then the key is the decoded object's own, its prototype is the ordinary + // one, and the prototype every other object shares is untouched. + assertTrue(Object.hasOwn(decoded as object, "__proto__")); + assertIdentical(Object.getPrototypeOf(decoded), Object.prototype); + assertObjectEquals((decoded as Record)["__proto__"], { + polluted: "yes", + }); + assertUndefined(({} as Record)["polluted"]); + }); + + it("refuses a key given twice", async () => { + // Given a body naming one key with two values. + // When it is decoded. + const error = await refuse("name=first&name=second"); + + // Then it names the key instead of choosing between the values. + assertStringIncludes(error.message, 'Form key "name" is given twice.'); + }); + + it("refuses a part holding a value and more keys at once", async () => { + // Given a key that is both a leaf and a branch. + // When it is decoded. + const error = await refuse("a=1&a[b]=2"); + + // Then it names the part that would have to be both. + assertStringIncludes(error.message, "a value and more keys at once"); + }); + + it("refuses an empty bracket before the last part", async () => { + // Given an append in the middle of a key, where the position is a guess. + // When it is decoded. + const error = await refuse("a[][b]=1"); + + // Then it says where the empty bracket is. + assertStringIncludes(error.message, "empty bracket before its last part"); + }); + + it("refuses a key that brackets cannot be read out of", async () => { + // Given keys with unbalanced or leading brackets. + const malformed = ["a[b=1", "[a]=1", "a]b[=1"]; + + // When each is decoded. + const errors = await Promise.all(malformed.map((body) => refuse(body))); + + // Then each one names the key it could not read. + for (const error of errors) { + assertStringIncludes(error.message, "bracketed parts"); + } + }); +}); diff --git a/src/http/decode-form.ts b/src/http/decode-form.ts new file mode 100644 index 0000000..d1f3316 --- /dev/null +++ b/src/http/decode-form.ts @@ -0,0 +1,160 @@ +import type { RequestDecoder } from "./request-decoder.js"; + +/** + * Reads an `application/x-www-form-urlencoded` body with bracketed nesting. + * + * A wire format, and not one service's dialect. Stripe, Rails and PHP + * applications all speak it, and all three write nesting the same way. + * + * ```text + * line_items[0][price_data][unit_amount]=250 + * ``` + * + * becomes + * + * ```json + * { "line_items": [{ "price_data": { "unit_amount": "250" } }] } + * ``` + * + * Every leaf is a string. A form body carries no types, and guessing at them + * would make `quantity=1` and `postcode=01234` disagree about what a digit is. + * The resource's own creation behaviour converts what it needs. + * + * The header is ignored. What a body says it is and what it holds are two + * facts, and a decoder chosen by hand has already settled the first. + * + * ## What it refuses + * + * A real encoder emits well-formed keys. The cases below are hostile or + * mistaken input, and each throws a `SyntaxError`, the way a malformed JSON + * body already does. + * + * - A key given twice (`name=a&name=b`). Which one wins is a guess, and losing + * half a request quietly is worse than stopping. + * - A key needing one part to hold both a value and more keys (`a=1&a[b]=2`). + * - An empty bracket anywhere but the end (`a[][b]=1`). + * - A key brackets cannot be read out of (`a[b`, `[a]`). + */ +export const decodeForm: RequestDecoder = async (request) => { + const body = await request.text(); + const root = new Map(); + + for (const [key, value] of new URLSearchParams(body)) { + insert(root, key, value); + } + + return materialise(root); +}; + +/** A branch of the tree, as opposed to a leaf holding one value. */ +const isBranch = (held: unknown): held is Map => + held instanceof Map; + +/** A key is a name followed by any number of bracketed parts. */ +const keyPattern = /^(?[^[\]]+)(?(?:\[[^[\]]*\])*)$/u; +const partPattern = /\[(?[^[\]]*)\]/gu; +const indexPattern = /^\d+$/u; + +/** The parts of one form key, outermost first. */ +function partsOf(key: string): string[] { + const match = keyPattern.exec(key); + const name = match?.groups?.["name"]; + + if (name === undefined) { + throw new SyntaxError( + `Form key "${key}" is not a name followed by bracketed parts.`, + ); + } + + const parts = [name]; + + for (const part of (match?.groups?.["parts"] ?? "").matchAll(partPattern)) { + parts.push(part.groups?.["part"] ?? ""); + } + + return parts; +} + +/** Puts one key's value into the tree, growing branches on the way down. */ +function insert(root: Map, key: string, value: string): void { + const parts = partsOf(key); + let node = root; + + for (const [depth, part] of parts.entries()) { + const last = depth === parts.length - 1; + + if (part === "" && !last) { + throw new SyntaxError( + `Form key "${key}" has an empty bracket before its last part.`, + ); + } + + // An empty bracket appends, and the position it takes is the branch's size. + const name = part === "" ? String(node.size) : part; + + if (last) { + if (node.has(name)) { + throw new SyntaxError(`Form key "${key}" is given twice.`); + } + + node.set(name, value); + return; + } + + node = branchAt(node, name, key); + } +} + +/** The branch below `name`, created when it is the first key to need one. */ +function branchAt( + node: Map, + name: string, + key: string, +): Map { + const held = node.get(name); + + if (isBranch(held)) { + return held; + } + + if (held !== undefined) { + throw new SyntaxError( + `Form key "${key}" needs "${name}" to hold a value and more keys at once.`, + ); + } + + const branch = new Map(); + node.set(name, branch); + + return branch; +} + +/** + * Turns the tree into arrays and objects. + * + * A branch whose every part is a run of digits becomes an array. The digits + * order the entries and do not position them, so `a[0]`, `a[5]` and `a[9]` + * give three elements and never a sparse array of ten. A real encoder counts + * from zero, where the two readings agree. + */ +function materialise(node: Map): unknown { + const entries = [...node].map( + ([name, held]) => + [name, isBranch(held) ? materialise(held) : held] as const, + ); + + if ( + entries.length > 0 && + entries.every(([name]) => indexPattern.test(name)) + ) { + return entries + .toSorted(([one], [other]) => Number(one) - Number(other)) + .map(([, held]) => held); + } + + /* + * `Object.fromEntries` defines own data properties, so a `__proto__` part of + * a hostile key lands beside the others and reaches no prototype. + */ + return Object.fromEntries(entries); +} diff --git a/src/index.ts b/src/index.ts index b78f638..9985994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export { type SemanticOperationContext, type SemanticOperationOverride, } from "./http/operation.js"; +export { decodeForm } from "./http/decode-form.js"; export { decodeJson, type RequestDecoder } from "./http/request-decoder.js"; export { RestResource } from "./rest-resource.js"; export {