Skip to content

Commit 04bf856

Browse files
committed
Fix request body parsing when Content-Type header is missing
Some HTTP clients (like Lobsters' Sponge) don't set Content-Type header for POST requests with form data. In this case, Hono's parseBody() returns an empty object because it defaults to text/plain. This fix manually parses the body as URL-encoded form data when Content-Type is missing or set to text/plain. Also adds comprehensive unit tests for requestBody() function.
1 parent 041483c commit 04bf856

File tree

2 files changed

+231
-1
lines changed

2 files changed

+231
-1
lines changed

src/helpers.test.ts

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,217 @@
1+
import { Hono } from "hono";
12
import { describe, expect, it } from "vitest";
2-
import { base64Url, randomBytes, URL_SAFE_REGEXP } from "./helpers";
3+
import { z } from "zod";
4+
import {
5+
base64Url,
6+
randomBytes,
7+
requestBody,
8+
URL_SAFE_REGEXP,
9+
} from "./helpers";
310

411
describe("Helpers", () => {
12+
describe("requestBody", () => {
13+
const schema = z.object({
14+
client_id: z.string(),
15+
client_secret: z.string(),
16+
grant_type: z.string().optional(),
17+
});
18+
19+
it("parses application/json content type", async () => {
20+
expect.assertions(1);
21+
22+
const app = new Hono();
23+
app.post("/test", async (c) => {
24+
const result = await requestBody(c.req, schema);
25+
return c.json(result);
26+
});
27+
28+
const response = await app.request("/test", {
29+
method: "POST",
30+
headers: { "Content-Type": "application/json" },
31+
body: JSON.stringify({
32+
client_id: "test-id",
33+
client_secret: "test-secret",
34+
}),
35+
});
36+
37+
const result = await response.json();
38+
expect(result).toEqual({
39+
success: true,
40+
data: { client_id: "test-id", client_secret: "test-secret" },
41+
});
42+
});
43+
44+
it("parses application/x-www-form-urlencoded content type", async () => {
45+
expect.assertions(1);
46+
47+
const app = new Hono();
48+
app.post("/test", async (c) => {
49+
const result = await requestBody(c.req, schema);
50+
return c.json(result);
51+
});
52+
53+
const body = new URLSearchParams();
54+
body.set("client_id", "test-id");
55+
body.set("client_secret", "test-secret");
56+
57+
const response = await app.request("/test", {
58+
method: "POST",
59+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
60+
body: body.toString(),
61+
});
62+
63+
const result = await response.json();
64+
expect(result).toEqual({
65+
success: true,
66+
data: { client_id: "test-id", client_secret: "test-secret" },
67+
});
68+
});
69+
70+
it("parses form data without Content-Type header", async () => {
71+
expect.assertions(1);
72+
73+
const app = new Hono();
74+
app.post("/test", async (c) => {
75+
const result = await requestBody(c.req, schema);
76+
return c.json(result);
77+
});
78+
79+
// Create a request without Content-Type header
80+
// (simulating clients like Lobsters' Sponge)
81+
const body =
82+
"client_id=test-id&client_secret=test-secret&grant_type=authorization_code";
83+
const request = new Request("http://localhost/test", {
84+
method: "POST",
85+
body,
86+
});
87+
// Remove the default Content-Type that might be set
88+
request.headers.delete("Content-Type");
89+
90+
const response = await app.fetch(request);
91+
const result = await response.json();
92+
93+
expect(result).toEqual({
94+
success: true,
95+
data: {
96+
client_id: "test-id",
97+
client_secret: "test-secret",
98+
grant_type: "authorization_code",
99+
},
100+
});
101+
});
102+
103+
it("parses form data with text/plain Content-Type", async () => {
104+
expect.assertions(1);
105+
106+
const app = new Hono();
107+
app.post("/test", async (c) => {
108+
const result = await requestBody(c.req, schema);
109+
return c.json(result);
110+
});
111+
112+
const body = "client_id=test-id&client_secret=test-secret";
113+
const response = await app.request("/test", {
114+
method: "POST",
115+
headers: { "Content-Type": "text/plain" },
116+
body,
117+
});
118+
119+
const result = await response.json();
120+
expect(result).toEqual({
121+
success: true,
122+
data: { client_id: "test-id", client_secret: "test-secret" },
123+
});
124+
});
125+
126+
it("parses form data with text/plain;charset=UTF-8 Content-Type", async () => {
127+
expect.assertions(1);
128+
129+
const app = new Hono();
130+
app.post("/test", async (c) => {
131+
const result = await requestBody(c.req, schema);
132+
return c.json(result);
133+
});
134+
135+
const body = "client_id=test-id&client_secret=test-secret";
136+
const response = await app.request("/test", {
137+
method: "POST",
138+
headers: { "Content-Type": "text/plain;charset=UTF-8" },
139+
body,
140+
});
141+
142+
const result = await response.json();
143+
expect(result).toEqual({
144+
success: true,
145+
data: { client_id: "test-id", client_secret: "test-secret" },
146+
});
147+
});
148+
149+
it("handles URL-encoded special characters correctly", async () => {
150+
expect.assertions(1);
151+
152+
const app = new Hono();
153+
app.post("/test", async (c) => {
154+
const result = await requestBody(c.req, schema);
155+
return c.json(result);
156+
});
157+
158+
// Test with URL-encoded values
159+
const body = "client_id=test%2Bid&client_secret=secret%26value";
160+
const response = await app.request("/test", {
161+
method: "POST",
162+
headers: { "Content-Type": "text/plain" },
163+
body,
164+
});
165+
166+
const result = await response.json();
167+
expect(result).toEqual({
168+
success: true,
169+
data: { client_id: "test+id", client_secret: "secret&value" },
170+
});
171+
});
172+
173+
it("returns validation error for invalid data", async () => {
174+
expect.assertions(2);
175+
176+
const app = new Hono();
177+
app.post("/test", async (c) => {
178+
const result = await requestBody(c.req, schema);
179+
return c.json(result);
180+
});
181+
182+
const body = "invalid_field=value";
183+
const response = await app.request("/test", {
184+
method: "POST",
185+
headers: { "Content-Type": "text/plain" },
186+
body,
187+
});
188+
189+
const result = await response.json();
190+
expect(result.success).toBe(false);
191+
expect(result.error).toBeDefined();
192+
});
193+
194+
it("handles empty body gracefully", async () => {
195+
expect.assertions(2);
196+
197+
const app = new Hono();
198+
app.post("/test", async (c) => {
199+
const result = await requestBody(c.req, schema);
200+
return c.json(result);
201+
});
202+
203+
const response = await app.request("/test", {
204+
method: "POST",
205+
headers: { "Content-Type": "text/plain" },
206+
body: "",
207+
});
208+
209+
const result = await response.json();
210+
expect(result.success).toBe(false);
211+
expect(result.error).toBeDefined();
212+
});
213+
});
214+
5215
describe("base64Url", () => {
6216
it("returns a URL safe string", () => {
7217
expect.assertions(2);

src/helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,26 @@ export async function requestBody<T extends z.ZodType = z.ZodTypeAny>(
1616
return await schema.safeParseAsync(json);
1717
}
1818

19+
// Some clients (like Lobsters' Sponge) don't set Content-Type header for
20+
// POST requests with form data. In this case, Hono's parseBody() returns
21+
// an empty object because it defaults to text/plain.
22+
// We need to manually parse the body as URL-encoded form data.
23+
if (
24+
contentType === undefined ||
25+
contentType === "text/plain" ||
26+
contentType.startsWith("text/plain;")
27+
) {
28+
const text = await req.text();
29+
if (text?.includes("=")) {
30+
const params = new URLSearchParams(text);
31+
const parsed: Record<string, string> = {};
32+
for (const [key, value] of params) {
33+
parsed[key] = value;
34+
}
35+
return await schema.safeParseAsync(parsed);
36+
}
37+
}
38+
1939
const formData = await req.parseBody();
2040
return await schema.safeParseAsync(formData);
2141
}

0 commit comments

Comments
 (0)