fix(studio): handle invalid TanStack API request bodies

This commit is contained in:
Alaister Young committed 2026-09-29 14:06:12 -07:00
1 parent 13de8189c9
commit 6cd65d2b8a
2 files changed
+244 -8

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { describe, expect, it, vi } from 'vitest'
import { toWebHandler } from './api'
const createRequest = (body?: string, contentType = 'application/json') =>
new Request('http://localhost/api/test', {
method: 'POST',
headers: { 'content-type': contentType },
body,
})
describe('toWebHandler request parsing', () => {
it.each(
['application/json', 'application/ld+json', 'Application/JSON; charset=utf-8'].flatMap(
(contentType) =>
['{', '[1,', 'undefined', ' ', '{"private":"value",}'].map((body) => ({
contentType,
body,
}))
)
)(
'returns 400 without calling the handler for malformed $contentType: $body',
async ({ body, contentType }) => {
const handler = vi.fn()
const response = await toWebHandler(handler)({ request: createRequest(body, contentType) })
expect(response.status).toBe(400)
expect(await response.text()).toBe('Invalid JSON')
expect(handler).not.toHaveBeenCalled()
}
)
it.each<[string | undefined, unknown]>([
['{"query":"select 1"}', { query: 'select 1' }],
['[1,"two",null]', [1, 'two', null]],
['"hello"', 'hello'],
['42', 42],
['true', true],
['false', false],
['null', null],
['', {}],
[undefined, {}],
])('passes valid JSON %s to the handler', async (body, expected) => {
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
expect(req.body).toEqual(expected)
res.status(201).setHeader('x-handler', 'called')
res.json({ success: true })
})
const response = await toWebHandler(handler)({ request: createRequest(body) })
expect(handler).toHaveBeenCalledOnce()
expect(response.status).toBe(201)
expect(response.headers.get('x-handler')).toBe('called')
expect(response.headers.get('content-type')).toBe('application/json')
expect(await response.json()).toEqual({ success: true })
})
it.each(['application/json; charset=utf-8', 'application/ld+json', 'Application/JSON'])(
'accepts JSON content type %s',
async (contentType) => {
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => res.json(req.body))
const response = await toWebHandler(handler)({
request: createRequest('{"name":"café"}', contentType),
})
expect(await response.json()).toEqual({ name: 'café' })
}
)
it.each<[string, string, unknown]>([
[
'name=hello+world&value=%26%3D',
'application/x-www-form-urlencoded',
{
name: 'hello world',
value: '&=',
},
],
['{', 'text/plain', '{'],
['{', 'text/plain; note="application/json"', '{'],
['{', 'application/jsonx', '{'],
])('preserves non-JSON request bodies', async (body, contentType, expected) => {
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
expect(req.body).toEqual(expected)
res.end('ok')
})
const response = await toWebHandler(handler)({ request: createRequest(body, contentType) })
expect(handler).toHaveBeenCalledOnce()
expect(await response.text()).toBe('ok')
})
it.each(['GET', 'HEAD'])('leaves %s bodies undefined', async (method) => {
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
expect(req.method).toBe(method)
expect(req.body).toBeUndefined()
res.end()
})
await toWebHandler(handler)({
request: new Request('http://localhost/api/test', {
method,
headers: { 'content-type': 'application/json' },
}),
})
expect(handler).toHaveBeenCalledOnce()
})
it('preserves the URL, headers and route parameter precedence', async () => {
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
expect(req.url).toBe('/api/test?ref=search&tag=one&tag=two&empty=')
expect(req.headers['x-custom']).toBe('value')
expect(req.query).toEqual({ ref: 'route', tag: ['one', 'two'], empty: '' })
res.end('ok')
})
await toWebHandler(handler)({
request: new Request('http://localhost/api/test?ref=search&tag=one&tag=two&empty=', {
headers: { 'X-Custom': 'value' },
}),
params: { ref: 'route', omitted: undefined },
})
expect(handler).toHaveBeenCalledOnce()
})
it.each(['%', '%ZZ', '%E0%A4%A', '%FF'])(
'preserves undecodable cookie values: %s',
async (bad) => {
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
expect(req.cookies).toEqual({ bad, good: 'hello world', token: 'a=b', empty: '' })
res.json({ success: true })
})
const response = await toWebHandler(handler)({
request: new Request('http://localhost/api/test', {
headers: { cookie: `bad=${bad}; good=hello%20world; token=a=b; empty=` },
}),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
expect(handler).toHaveBeenCalledOnce()
}
)
it('does not hide a body-read failure as invalid JSON', async () => {
const request = createRequest('{}')
const error = new Error('Body read failed')
vi.spyOn(request, 'text').mockRejectedValue(error)
const handler = vi.fn()
await expect(toWebHandler(handler)({ request })).rejects.toBe(error)
expect(handler).not.toHaveBeenCalled()
})
it('does not turn a handler SyntaxError into a request parsing error', async () => {
const error = new SyntaxError('Handler failed')
const handler = vi.fn(() => {
throw error
})
await expect(toWebHandler(handler)({ request: createRequest('{}') })).rejects.toBe(error)
expect(handler).toHaveBeenCalledOnce()
})
it('passes through a Web Response returned by the handler', async () => {
const expected = new Response('direct', { status: 202, headers: { 'x-direct': 'yes' } })
const response = await toWebHandler(() => expected)({ request: createRequest('{}') })
expect(response).toBe(expected)
expect(await response.text()).toBe('direct')
})
it('keeps streamed responses open for chunks written after the handler returns', async () => {
let finish: (() => void) | undefined
const response = await toWebHandler((_req, res) => {
res.writeHead(202, { 'content-type': 'text/event-stream', 'x-stream': 'yes' })
res.write('first\n')
finish = () => res.end('last\n')
})({ request: createRequest('{}') })
expect(response.status).toBe(202)
expect(response.headers.get('x-stream')).toBe('yes')
const reader = response.body!.getReader()
const decoder = new TextDecoder()
expect(decoder.decode((await reader.read()).value)).toBe('first\n')
finish!()
expect(decoder.decode((await reader.read()).value)).toBe('last\n')
expect((await reader.read()).done).toBe(true)
})
it('preserves close and aborted events from the request signal', async () => {
const controller = new AbortController()
const onClose = vi.fn()
const onAborted = vi.fn()
await toWebHandler((req, res) => {
req.on('close', onClose)
req.once('aborted', onAborted)
res.end('ok')
})({ request: new Request('http://localhost/api/test', { signal: controller.signal }) })
controller.abort()
expect(onClose).toHaveBeenCalledOnce()
expect(onAborted).toHaveBeenCalledOnce()
})
})
+31 -8
View File
@@ -20,9 +20,19 @@ interface RouteCtx {
params?: Record<string, string | undefined>
}
class InvalidJsonError extends Error {}
export function toWebHandler(handler: NextHandler) {
return async ({ request, params = {} }: RouteCtx): Promise<Response> => {
const req = await buildRequest(request, params)
let req: NextApiRequest
try {
req = await buildRequest(request, params)
} catch (error) {
if (error instanceof InvalidJsonError) {
return new Response('Invalid JSON', { status: 400, statusText: 'Invalid JSON' })
}
throw error
}
const { res, finalize } = buildResponse()
const result = await handler(req, res)
@@ -64,7 +74,12 @@ async function buildRequest(
const eq = trimmed.indexOf('=')
const name = eq >= 0 ? trimmed.slice(0, eq) : trimmed
const value = eq >= 0 ? trimmed.slice(eq + 1) : ''
cookies[name] = decodeURIComponent(value)
cookies[name] = value
try {
cookies[name] = decodeURIComponent(value)
} catch {
// Next's cookie parser keeps values that cannot be URI-decoded.
}
}
}
@@ -82,15 +97,23 @@ async function buildRequest(
}
let body: unknown = undefined
if (method !== 'GET' && method !== 'HEAD' && request.body) {
const contentType = request.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
if (method !== 'GET' && method !== 'HEAD') {
const contentType = (request.headers.get('content-type') ?? '')
.split(';', 1)[0]
.trim()
.toLowerCase()
if (contentType === 'application/json' || contentType === 'application/ld+json') {
const text = await request.text()
body = text ? JSON.parse(text) : undefined
} else if (contentType.includes('application/x-www-form-urlencoded')) {
try {
// Next treats an empty JSON body as an empty object.
body = text ? JSON.parse(text) : {}
} catch {
throw new InvalidJsonError('Invalid JSON')
}
} else if (request.body && contentType === 'application/x-www-form-urlencoded') {
const text = await request.text()
body = Object.fromEntries(new URLSearchParams(text))
} else {
} else if (request.body) {
body = await request.text()
}
}