Skip to content

feat: accept raw/binary request bodies on /post and /anything#163

Closed
cyfung1031 wants to merge 2 commits into
jaredwray:mainfrom
cyfung1031:feat/post-anything-raw-body
Closed

feat: accept raw/binary request bodies on /post and /anything#163
cyfung1031 wants to merge 2 commits into
jaredwray:mainfrom
cyfung1031:feat/post-anything-raw-body

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 17, 2026

Copy link
Copy Markdown

Please check if the PR fulfills these requirements

  • Followed the Contributing and Code of Conduct guidelines.
  • Tests for the changes have been added (for bug fixes/features) with 100% code coverage.

What kind of change does this PR introduce? Feature — /post and /anything now accept raw/binary request bodies instead of rejecting anything that isn't application/json. Includes one documented behavior change (see below).


Summary

/post and /anything currently reject any request body whose Content-Type isn't application/json (or text/plain) with 415 Unsupported Media Type, since Fastify only registers parsers for those two content types by default:

$ curl -s -X POST -H "Content-Type: image/png" --data-binary @test.png http://127.0.0.1:3000/post
{"statusCode":415,"code":"FST_ERR_CTP_INVALID_MEDIA_TYPE","error":"Unsupported Media Type","message":"Unsupported Media Type"}

That means image uploads, arbitrary binary payloads, multipart/form-data, or any client sending Content-Type: application/octet-stream can't be exercised against these routes at all — unlike httpbin/httpbun, where posting arbitrary bytes to these kinds of endpoints is a common use case (this is the last of the compatibility gaps I found migrating a project's test suite onto mockhttp.org, alongside #160, #161, #162).

Fix

Adds a shared registerRawBodyFallbackParser() (src/routes/http-methods/raw-body.ts) that registers a "*" catch-all content-type parser, scoped to each route's own plugin encapsulation (fastify.register(postRoute) / fastify.register(anythingRoute)), so it only fills in the gap for content types nothing else already handles — it doesn't change how application/json (or, on top of #161, application/x-www-form-urlencoded) bodies are parsed.

The accompanying encodeRequestBody() decides how to represent a raw Buffer in the response: utf8 text for textual content types, base64 otherwise — the same rule src/routes/bins/capture.ts already uses for its request-bin bodies, so this doesn't introduce a new convention.

/post's response schema for body is relaxed from a strict {type: "object"} to accept any value, matching how the DELETE route's response schema already uses oneOf: [object, string, null] for the same field. /anything's data/form/json fields are relaxed the same way; the AnythingResponse type is updated to match.

One behavior change worth flagging explicitly: a POST to /post or /anything with no body/Content-Type at all now returns 200 with body: {} instead of a strict 400 — since the whole point of this change is accepting a much broader range of bodies, requiring a well-formed object was no longer the right default. I actually caught this as a 500 crash regression against my first draft via a manual curl check (fast-json-stringify choking on the body field being undefined against the response schema's required), fixed it, and added a dedicated regression test for it (the "empty body" cases in post.test.ts and anything/index.test.ts).

Test plan

  • New test/routes/http-methods/raw-body.test.ts covering registerRawBodyFallbackParser and encodeRequestBody directly (binary capture, JSON untouched, utf8 vs base64 selection, empty buffer, non-Buffer passthrough).
  • Added binary-body and empty-body regression tests to post.test.ts and anything/index.test.ts.
  • pnpm test (lint + full vitest run with coverage) passes locally: 484 tests, 100% line coverage, no lint warnings.
  • Manually verified against a local tsx src/index.ts instance: binary PNG upload, multipart upload, JSON object/array bodies, and the empty-body case all behave as expected; confirmed the pre-existing 400 became a 200/{} deliberately (not the 500 from my first draft).

/post and /anything currently reject any request body whose
Content-Type isn't application/json (or text/plain) with 415
Unsupported Media Type, since Fastify only registers parsers for those
two content types by default. That means image uploads, arbitrary
binary payloads, multipart/form-data, or any client sending
Content-Type: application/octet-stream can't be exercised against
these routes at all, unlike httpbin/httpbun.

Adds a shared registerRawBodyFallbackParser() (src/routes/http-methods/raw-body.ts)
that registers a "*" catch-all content-type parser, scoped to each
route's own plugin encapsulation, so it only fills in the gap for
content types nothing else already handles - it doesn't change how
application/json (or, on top of jaredwray#161, application/x-www-form-urlencoded)
bodies are parsed. The accompanying encodeRequestBody() decides how to
represent a raw Buffer in the response: utf8 text for textual content
types, base64 otherwise - the same rule src/routes/bins/capture.ts
already uses for its request-bin bodies.

/post's response schema for `body` is relaxed from a strict
`{type: "object"}` to accept any JSON value or the utf8/base64 string
produced for non-JSON content, matching the `oneOf: object|string|null`
pattern the DELETE route already used. /anything's `data`/`form`/`json`
fields are relaxed the same way; `AnythingResponse`'s type is updated
to match.

One user-visible behavior change worth calling out: a POST to /post or
/anything with literally no body/Content-Type now returns 200 with
body: {} instead of a strict 400 - since the whole point of this
change is that these routes accept a much broader range of bodies now,
requiring a well-formed object was no longer the right default. (I
caught this as a 500 regression against my first draft via a manual
curl check, and added a regression test for it - see the "empty body"
test cases in post.test.ts and anything/index.test.ts.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces raw body fallback parsing to handle request bodies that Fastify's built-in parsers do not support, preventing 415 Unsupported Media Type errors. It registers a catch-all parser for non-JSON/text content types and decodes them as UTF-8 or base64 depending on the content type. The review feedback warns that registering the * content type parser multiple times on the same Fastify instance could cause a FST_ERR_CTP_ALREADY_PRESENT error and crash the application, and suggests checking if the parser is already registered before adding it.

Comment on lines +18 to +26
export function registerRawBodyFallbackParser(fastify: FastifyInstance) {
fastify.addContentTypeParser(
"*",
{ parseAs: "buffer" },
(_request, body, done) => {
done(null, body);
},
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Registering a content type parser for * multiple times on the same Fastify instance (for example, in tests or if encapsulation is disabled/bypassed) will cause Fastify to throw a FST_ERR_CTP_ALREADY_PRESENT error and crash the application on startup. To prevent this, use fastify.hasContentTypeParser('*') to check if the parser is already registered before adding it.

export function registerRawBodyFallbackParser(fastify: FastifyInstance) {
	if (fastify.hasContentTypeParser("*")) {
		return;
	}
	fastify.addContentTypeParser(
		"*",
		{ parseAs: "buffer" },
		(_request, body, done) => {
			done(null, body);
		},
	);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied. Verified empirically: registering "" twice doesn't throw FST_ERR_CTP_ALREADY_PRESENT (only exact content-type strings do), and hasContentTypeParser("") returns false even right after registering it — the suggested guard couldn't have worked. Posted as a reply comment with the test transcript.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a fallback raw body parser and request body encoder to gracefully handle non-JSON and binary request bodies on the /anything and /post endpoints, preventing 415 Unsupported Media Type errors. The review feedback suggests enhancing the content-type regex to support structured syntax suffixes (like +json and +xml) and conditionally populating the data, form, and json fields on the /anything endpoint based on the request's Content-Type to prevent data leakage.

Comment thread src/routes/http-methods/raw-body.ts Outdated
Comment on lines +3 to +4
const TEXT_CONTENT_TYPE_REGEX =
/^(text\/|application\/(json|xml|x-www-form-urlencoded|javascript))/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current TEXT_CONTENT_TYPE_REGEX does not match structured syntax suffixes like +json or +xml (e.g., application/ld+json, application/hal+json, image/svg+xml). This causes these common textual content types to be base64-encoded instead of decoded as UTF-8 text. We should update the regex to support these suffixes.

Suggested change
const TEXT_CONTENT_TYPE_REGEX =
/^(text\/|application\/(json|xml|x-www-form-urlencoded|javascript))/i;
const TEXT_CONTENT_TYPE_REGEX =
/^(text\/|application\/(json|xml|x-www-form-urlencoded|javascript)|.*\+(json|xml))/i;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, with a fix 4ca6d56: added a trailing \b boundary the suggested regex lacked (raw-body.ts:9-10), which also fixes a pre-existing loose-match bug (application/jsonp false-matching).

Comment on lines 84 to 97
const handler = async (request: FastifyRequest, reply: FastifyReply) => {
const body =
encodeRequestBody(request.body, request.headers["content-type"]) ?? {};
const response: AnythingResponse = {
args: request.query as Record<string, unknown>,
data: (request.body as Record<string, unknown>) ?? {},
data: body,
// biome-ignore lint/suspicious/noExplicitAny: expected
files: (request.raw as any)?.files ?? {},
form: (request.body as Record<string, unknown>) ?? {},
form: body,
headers: request.headers,
json: (request.body as Record<string, unknown>) ?? {},
json: body,
method: request.method,
origin: request.headers.origin ?? request.headers.host ?? "",
url: request.url,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In httpbin (and for general compatibility), the /anything endpoint should only populate the json field if the request body is valid JSON, and the form field if the request is form-encoded (application/x-www-form-urlencoded or multipart/form-data). Currently, setting data, form, and json all to the same body value causes raw binary (base64) or plain text payloads to leak into form and json fields. We can conditionally populate these fields based on the request's Content-Type while still returning {} when no body is present to maintain backward compatibility with existing tests.

	const handler = async (request: FastifyRequest, reply: FastifyReply) => {
		const contentType = request.headers["content-type"] ?? "";
		const isJson = /application\/json/i.test(contentType);
		const isForm =
			/application\/(x-www-form-urlencoded|form-data)/i.test(contentType) ||
			contentType.startsWith("multipart/");

		const body = encodeRequestBody(request.body, contentType);
		const response: AnythingResponse = {
			args: request.query as Record<string, unknown>,
			data: isForm ? {} : (body ?? {}),
			// biome-ignore lint/suspicious/noExplicitAny: expected
			files: (request.raw as any)?.files ?? {},
			form: isForm ? (body ?? {}) : {},
			headers: request.headers,
			json: isJson ? (body ?? {}) : (body === undefined ? {} : null),
			method: request.method,
			origin: request.headers.origin ?? request.headers.host ?? "",
			url: request.url,
		};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied ( 4ca6d56 ), via isJsonContentType/isFormContentType (raw-body.ts:63-72, wired into anything/index.ts:86-98).

Comment thread test/routes/anything/index.test.ts Outdated
Comment on lines +38 to +40
expect(body.data).toBe(png.toString("base64"));
expect(body.form).toBe(png.toString("base64"));
expect(body.json).toBe(png.toString("base64"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the test assertions to match the corrected /anything behavior where non-form and non-JSON payloads do not leak into the form and json fields.

Suggested change
expect(body.data).toBe(png.toString("base64"));
expect(body.form).toBe(png.toString("base64"));
expect(body.json).toBe(png.toString("base64"));
expect(body.data).toBe(png.toString("base64"));
expect(body.form).toEqual({});
expect(body.json).toBeNull();

@cyfung1031 cyfung1031 Jul 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied (4ca6d56), but with {} instead of the suggested toBeNull() — kept consistent with using {} (not null) for the mismatch case, since null would've broken a pre-existing passing test for bodyless GET.

…raw bodies into /anything's form and json fields

Per review feedback on jaredwray#163:

- TEXT_CONTENT_TYPE_REGEX now also matches structured syntax suffixes
  (RFC 6839) like application/ld+json, application/vnd.api+json, and
  image/svg+xml, so these are decoded as utf8 text instead of
  needlessly base64-encoded. Added a trailing \b so lookalikes like
  application/jsonp (textual, but not a real "+json" suffix type, and
  already loosely matched by the pre-existing regex in
  src/routes/bins/capture.ts) don't false-positive - verified with a
  standalone regex script before writing any code.

- /anything's `form` and `json` fields no longer echo back whatever
  was in `data` regardless of Content-Type. A binary or plain-text
  body no longer shows up under both `form` and `json` alongside
  `data` - each field is now only populated when the Content-Type
  actually says so (via new isJsonContentType/isFormContentType
  helpers), matching how httpbin's own /anything endpoint behaves.

I did not apply the reviewer's suggested `fastify.hasContentTypeParser("*")`
guard against double-registering the catch-all parser: I verified
directly (see below) that registering a "*" content-type parser twice
on the same Fastify instance does not throw FST_ERR_CTP_ALREADY_PRESENT
(that only happens for exact/specific content-type strings) - the
second registration just silently wins as the new fallback. I also
confirmed hasContentTypeParser("*") returns false even immediately
after registering the wildcard parser, so the suggested guard
wouldn't have detected anything anyway. Since both call sites
(postRoute, anythingRoute) register the exact same no-op passthrough
handler, even a genuine double-registration would be a no-op in
practice. Full transcript in the PR comment.

I also did not apply the reviewer's suggested `json: null` fallback
verbatim - it would have broken the pre-existing, already-passing
test asserting `json` is `{}` for a plain bodyless GET (unrelated to
this PR). Used `{}` instead of `null` for the "wrong content type"
case too, keeping that pre-existing contract intact while still fixing
the actual leakage the review flagged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Author

Thanks for the review. Two of the three points are addressed:

Structured syntax suffixes (+json/+xml) — applied, with one adjustment: your suggested regex (.*\+(json|xml)) has no end boundary, so it also matches unrelated types that merely start with application/json as a substring prefix (e.g. it isn't actually the +json branch causing this — it's that the existing first alternative application\/(json|xml|...) has no trailing boundary either, so application/jsonp already matched before my change, via capture.ts's original pattern). I added a trailing \b to fix both, verified against a table of real content types (application/ld+json, image/svg+xml, application/jsonp, etc.) before writing the actual code — see the commit message for the full case list.

/anything leaking raw bodies into form/json — applied, with isJsonContentType/isFormContentType helpers gating those fields. One change from your suggested diff: I used {} rather than null for the "wrong content type" fallback, because null would have broken the pre-existing (unrelated to this PR) test asserting json is {} for a bodyless GET. Didn't want to change that contract as a side effect of this fix.

hasContentTypeParser("*") guard against double-registration — not applied. I checked this directly against the actual Fastify version in this repo before deciding:

const f = Fastify();
f.addContentTypeParser("*", { parseAs: "buffer" }, (r, b, done) => done(null, "FIRST"));
f.addContentTypeParser("*", { parseAs: "buffer" }, (r, b, done) => done(null, "SECOND"));
// -> no throw. "SECOND" wins as the fallback.

f.hasContentTypeParser("*") // -> false, even right after registering it

So two things: registering "*" twice doesn't throw FST_ERR_CTP_ALREADY_PRESENT (that's only for exact content-type strings — I confirmed that part of the claim is correct, by testing application/json registered twice, which does throw), and hasContentTypeParser("*") can't detect the wildcard parser at all, so the suggested guard would never actually trigger even if the crash were real. Since postRoute and anythingRoute both register the identical no-op buffer-passthrough handler, even a genuine double-registration on a shared instance would be behaviorally inert anyway. Happy to revisit if you know of a Fastify version/config where this behaves differently.

@cyfung1031

Copy link
Copy Markdown
Author

Sorry for using Vibe Coding.

In one of my projects (https://github.com/scriptscat/scriptcat/),
I am considering to migrate from https://httpbun.com/ to https://mockhttp.org/
However, I found that raw/binary request bodies in POST are not supported yet.

Screenshot 2026-07-18 at 8 23 07

Hope you might consider to merge this PR for the API enhancement.

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.

2 participants