Skip to content

feat: add a form-urlencoded decoder with bracketed nesting - #24

Merged
hughgrigg merged 1 commit into
mainfrom
feat/form-decoder
Sep 2, 2026
Merged

feat: add a form-urlencoded decoder with bracketed nesting#24
hughgrigg merged 1 commit into
mainfrom
feat/form-decoder

Conversation

@hughgrigg

Copy link
Copy Markdown
Contributor

Stacked on #23, which adds the decode hook this fills in.

application/x-www-form-urlencoded is a wire format, and not one service's dialect. Stripe, Rails
and PHP applications all speak it, all three write nesting the same way, and none of them could be
simulated without a decoder for it. It belongs beside JSON for the same reason JSON is here.

line_items[0][price_data][unit_amount]=250&line_items[0][quantity]=1&expand[]=customer
{
  "line_items": [{ "price_data": { "unit_amount": "250" }, "quantity": "1" }],
  "expand": ["customer"]
}

Decisions

Every leaf stays 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, where the target shape is known.

Digits order the entries and do not position them. 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, and
this needs no cap on how large an index a hostile body may claim.

Four inputs throw a SyntaxError rather than being guessed at.

Body Refused because
name=a&name=b Which value wins is a guess
a=1&a[b]=2 a would 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

That follows the existing rule that decode failures stay loud. A real encoder emits well-formed
keys, so these are hostile or mistaken input, and a simulation quietly corrupted by them would be
worse than one that stops.

The content-type header is ignored. What a body claims to be and what it holds are two facts,
and choosing the decoder by hand has already settled the first.

Notes

A __proto__ key lands as an own data property and reaches no prototype, which Object.fromEntries
gives for free. There is a test pinning it.

The parser is a page of code and should stay one. Sparse arrays, dotted keys and qs compatibility
flags are each a decision a reader would have to hold in their head, and none is needed to simulate
an API.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 20 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 93 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 1805e14f-5cf1-4d3e-8a7f-1fb9be8ab1e5

📥 Commits

Reviewing files that changed from the base of the PR and between 56d066d and 17d1da9.

📒 Files selected for processing (4)
  • docs/request-bodies/README.md
  • src/http/decode-form.test.ts
  • src/http/decode-form.ts
  • src/index.ts

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feat/request-decoder to main September 2, 2026 09:28
`application/x-www-form-urlencoded` is a wire format, and not one service's
dialect. Stripe, Rails and PHP applications all speak it, all three write
nesting the same way, and none of them could be simulated without a decoder
for it. It belongs beside JSON for the same reason JSON is here.

`decodeForm` reads `line_items[0][price_data][unit_amount]=250` into nested
objects and arrays. Every leaf stays 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.

Two decisions worth stating, both taken to keep the parser a page long.

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, and this needs no cap on how
large an index a hostile body may claim.

Four inputs throw a `SyntaxError` rather than being guessed at: a key given
twice, a key needing one part to be both a value and a branch, an empty
bracket anywhere but the end, and a key brackets cannot be read out of. That
follows the existing rule that decode failures stay loud, and it means a
simulation can never be quietly corrupted by input a real encoder would never
emit.

A `__proto__` key lands as an own data property and reaches no prototype,
which `Object.fromEntries` gives for free. There is a test pinning it.
@hughgrigg
hughgrigg merged commit 45c39b7 into main Sep 2, 2026
6 checks passed
@hughgrigg
hughgrigg deleted the feat/form-decoder branch September 2, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant