mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Malformed JSON now returns `400 Invalid JSON` from the TanStack API
adapter instead of an unhandled 500. Empty JSON bodies and undecodable
cookies follow the existing Next parser behavior.
**Changed:**
- Handle JSON parsing failures before invoking the API handler, while
preserving body-read and downstream handler errors.
- Recognize exact JSON and JSON-LD media types, including case and
charset parameters.
- Keep raw cookie values when URI decoding fails.
**Added:**
- 43 adapter tests covering valid and invalid bodies, cookies, handler
isolation, response headers, streaming, and abort events.
## To test
- POST malformed JSON to `/api/parse-query` with `Content-Type:
application/json`; expect 400 with `Invalid JSON`. Repeat with JSON-LD.
- POST `{"sql":"select 1"}` to the same endpoint; expect 200. This
parses SQL without executing it against a database.
- Repeat the valid request with an undecodable cookie value; expect the
same successful result.
- Open an existing project and database settings; verify normal API
consumers remain usable.
Validation: 59 focused tests, 29 differential body cases plus malformed
cookies against installed Next parsers, Studio typecheck, lint ratchet,
scoped ESLint, formatting, knip, and both framework production builds
passed. Direct local HTTP checks passed for malformed, empty, valid, and
malformed-cookie requests. Next handlers, routes, environment files, and
dependencies are unchanged. Local TanStack browser checks passed for
signed-in project rendering, clean reload, and the untouched support
form. Database settings rendered its error state, but the banned-IP
service returned an upstream connection-timeout payload, so successful
list coverage remains unverified. Chrome blocked direct API-document
navigation; malformed-input behavior is covered by the separate HTTP and
parser checks.
The automatic staging preview ran native Next (confirmed from its build
output), providing an additional Next regression check: malformed JSON
and JSON-LD returned exact `400 Invalid JSON`; valid SQL parsing
returned 200; an undecodable cookie preserved the same valid response.
No SQL was executed. The local TanStack runtime and in-process adapter
checks passed; the combined TanStack deployment results follow.
The combined rollout preview (TanStack, QA commit
`f35b3c42d8ba6a714299b148d797b09107a7a6fe`) also passes deployed
malformed JSON/JSON-LD 400, valid SQL parsing 200, and
undecodable-cookie 200 with the same valid response. No SQL is executed
against a database.
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
214 lines
7.3 KiB
TypeScript
214 lines
7.3 KiB
TypeScript
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()
|
|
})
|
|
})
|