diff --git a/apps/studio/compat/next/api.test.ts b/apps/studio/compat/next/api.test.ts new file mode 100644 index 00000000000..28f64efd36d --- /dev/null +++ b/apps/studio/compat/next/api.test.ts @@ -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() + }) +}) diff --git a/apps/studio/compat/next/api.ts b/apps/studio/compat/next/api.ts index 15e46eac39f..5126da3fdfb 100644 --- a/apps/studio/compat/next/api.ts +++ b/apps/studio/compat/next/api.ts @@ -20,9 +20,19 @@ interface RouteCtx { params?: Record } +class InvalidJsonError extends Error {} + export function toWebHandler(handler: NextHandler) { return async ({ request, params = {} }: RouteCtx): Promise => { - 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() } }