feat(routes): support URL-encoded and multipart request bodies - #213
Merged
07prajwal2000 merged 3 commits intoAug 7, 2026
Merged
Conversation
Get Request Body only ever produced JSON. `application/json; charset=utf-8` missed the exact-match check and fell through to raw text, urlencoded bodies came back as an unusable FormData object, and multipart arrived as raw MIME. Body parsing moves out of the envelope adapter and behind a per-route allowlist. Each route stores its accepted content types in a new `http_route_config` table — a jsonb bag keyed by route, so the next per-route setting is a change to `lib/routeConfig.ts` rather than a migration. Routes with no row accept JSON, exactly as before. - JSON, urlencoded, multipart, octet-stream and text/plain are supported; anything else is a 415. Repeated form keys collect into an array, and multipart files stay native File objects so a JS Runner can read them. - The body is read only after the route matches, so the allowlist can gate the parse instead of auditing bytes already buffered. - WORKER_MAX_STREAM_SIZE (KB, default 8192) caps a request body at Bun.serve and again per request; over it is a 413. - Route schemas gain `file` and `blob` types with size and mime rules. - The create-route form picks the accepted types via a new MultiSelect, and the generated OpenAPI spec lists them instead of assuming JSON. Closes Fluxify-rest#161 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> @
The multipart/urlencoded parser and the file/blob schema types were each tested alone. These two run the real seam: a multipart request through dispatch into a bodySchema with file size and mime rules, and a urlencoded body against a string rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four graphs compiled and executed against a multipart-shaped requestBody: a JS runner reading a file's bytes and branching on them, the failure path for an empty upload, a File surviving a setvar round trip by reference, and a foreach over a repeated form field. The first of these caught a real bug: a part announced as 'text/plain;charset=utf-8' failed a 'text/plain' mimeTypes rule, because the schema parser compared the content type verbatim. It now compares the media type only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #161
Why
Get Request Body only ever produced JSON, and not reliably:
contentType === "application/json"was an exact match, soapplication/json; charset=utf-8fell through to.text()and the workflow got a string.application/x-www-form-urlencodedreturned a rawFormDataobject — not JSON-serializable, not usable by body-schema validation, no.fieldaccess.multipart/form-datahit.text()and arrived as raw MIME.The fix belongs in the transport, not the block: the same value feeds the block, the compiled emitter's
ctx.requestBody, the JS-runner globalgetRequestBody(), and body validation. Fixing only the block would leave the other three broken.What
Content types are now a per-route setting. A route accepts
application/jsonunless told otherwise; anything else is a415with a message naming what it does accept.application/jsonapplication/x-www-form-urlencodedmultipart/form-dataFileapplication/octet-streamBlobtext/plainRepeated keys (
tag=a&tag=b, two files under one name) collect into an array — silently dropping the second file of a two-file upload is worse than an uneven shape.POST/PUTonly;DELETEmay carry a body per RFC 9110, but we don't invite it.New
http_route_configtable (route_idPK /project_id/route_configjsonb / timestamps). An open bag rather than a column per setting, so the next per-route knob is a change tolib/routeConfig.tsand not a migration. Patches merge with jsonb||so a key written by a newer build isn't clobbered. Missing or unparseable config falls back to the default — a bad row must never take a route down.Parsing happens after the route matches.
envelopeFromHttpnow attaches abodyReaderanddispatchinvokes it with the matched route's allowlist, so an unacceptable content type is rejected without reading the body at all. Non-HTTP producers (NATS/BullMQ, the test-suite runner) still supply a plainbodyand are untouched. Async-reply requests buffer before the 202, since the request stream isn't guaranteed to outlive the response.Size cap:
WORKER_MAX_STREAM_SIZE(kilobytes, default 8192 = 8 MB), applied atBun.serveon both worker paths and re-checked per request →413. The execution child process clears its own env, so the value travels inExecutionBootstrap.Schema types:
fileandblobwithmaxSize/minSize/mimeTypes(an array or a comma-separated string).File[]already works viaarr+fileitems. Mime comparison uses the media type only —text/plain;charset=utf-8matches atext/plainrule.Portal: the create-route modal gained an Accepted content types picker (shown for POST/PUT), backed by a new
MultiSelectinpackages/components— HeroUI's Select in multiple mode with chips in the trigger. The generated OpenAPI spec now lists the route's real content types instead of assuming JSON.Tests
requestBody.spec.ts— 18 cases: charset tolerance, each content type, repeated keys,__proto__staying an own property, malformed body → 400, oversize byContent-Lengthand by actual bytes → 413.dispatch.spec.ts— 415 for a disallowed type, the default-JSON fallback, the parsed body reaching the graph, and parsed body → body-schema validation (multipart againstfilesize/mime rules, urlencoded against a string rule).compilerFormData.spec.ts— four compiled graphs run over a parsed form body: a JS runner reading a file's bytes then branching, the empty-upload failure path, aFilesurviving a setvar round trip by reference, and a foreach over a repeated field.schemaParser.test.ts/routeConfig.test.ts— file/blob rules, and config defaults and fallbacks.The compiled-graph test caught a real bug before this shipped: Bun reports a text part as
text/plain;charset=utf-8, which failed an exact-matchtext/plainmime rule. Hence the media-type normalization above.Repo lint green; 658 unit + 78 integration tests pass.
Notes for review
x-www-form-urlencoded(previously an unusableFormData) or an arbitrary content type (previously.text()) now gets a415until its accepted types are set. Existing routes keep working for JSON, which is what nearly all of them use.update-partialacceptsacceptedContentTypes; there is no route-settings screen yet, so the picker lives in the create modal until the portal settings page lands.routes/update(full PUT) is deliberately untouched.patchRouteConfig→ loader/compiler), which needs a live Postgres, and the MultiSelect in a browser.🤖 Generated with Claude Code