mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix: preserve function responses (#47920)
- adds up to: https://github.com/supabase/cli/pull/5862 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an “Error docs” link in Edge Function testing UI when an `sb-error-code` header is present. * **Bug Fixes** * Improved the Edge Function test proxy to consistently preserve upstream status, headers (including repeated headers), and response bodies without transformation. * Enhanced handling for invalid function URLs and upstream fetch failures. * **Tests** * Added unit, API, and Playwright E2E coverage for error docs linking and response proxy behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
4864031466
commit
83e6552d71
7 files changed
+301
-40
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
export type ResponseData = {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
headers: Record<string, string | string[]>
|
||||
body: string
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -1,4 +1,7 @@
|
||||
import { getAnchor } from '@ui/components/CustomHTMLElements/CustomHTMLElements.utils'
|
||||
|
||||
import { EdgeFunction } from '@/data/edge-functions/edge-function-query'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const generateCLICommands = ({
|
||||
selectedFunction,
|
||||
@@ -99,3 +102,18 @@ export const generateCLICommands = ({
|
||||
|
||||
return { managementCommands, secretCommands, invokeCommands }
|
||||
}
|
||||
|
||||
export const getEdgeFunctionErrorDocs = (headers: Record<string, string | string[]>) => {
|
||||
const header = Object.entries(headers).find(
|
||||
([name]) => name.toLowerCase() === 'sb-error-code'
|
||||
)?.[1]
|
||||
const code = (Array.isArray(header) ? header[0] : header)?.trim()
|
||||
const anchor = code ? getAnchor(code) : undefined
|
||||
|
||||
if (!code || !anchor) return undefined
|
||||
|
||||
return {
|
||||
code,
|
||||
href: `${DOCS_URL}/guides/functions/error-codes#${anchor}`,
|
||||
}
|
||||
}
|
||||
+25
-7
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { Loader2, Plus, Send, X } from 'lucide-react'
|
||||
import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import * as z from 'zod'
|
||||
|
||||
import { HTTP_METHODS } from './EdgeFunctionDetails.constants'
|
||||
import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types'
|
||||
import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils'
|
||||
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
|
||||
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
@@ -100,6 +101,7 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
|
||||
|
||||
const [response, setResponse] = useState<ResponseData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined
|
||||
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
|
||||
const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys })
|
||||
@@ -423,12 +425,28 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
|
||||
Headers
|
||||
</TabsTrigger>
|
||||
</div>
|
||||
<Badge
|
||||
variant={response.status >= 400 ? 'destructive' : 'success'}
|
||||
className="-translate-y-1"
|
||||
>
|
||||
{response.status}
|
||||
</Badge>
|
||||
<div className="-translate-y-1 flex items-center gap-2">
|
||||
{errorDocs !== undefined && (
|
||||
<Button
|
||||
asChild
|
||||
variant="text"
|
||||
size="tiny"
|
||||
icon={<BookOpen size={14} />}
|
||||
>
|
||||
<a
|
||||
href={errorDocs.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`View documentation for ${errorDocs.code} (opens in new tab)`}
|
||||
>
|
||||
Error docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
|
||||
{response.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TabsList>
|
||||
<TabsContent value="body" className="mt-0 flex-1 overflow-auto p-0">
|
||||
<CodeBlock
|
||||
|
||||
@@ -77,42 +77,21 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
redirect: 'manual', // don't follow the redirect and return response as is
|
||||
})
|
||||
|
||||
// Handle non-JSON responses
|
||||
let responseBody: string
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
// If JSON, parse and stringify to ensure it's valid JSON
|
||||
const jsonBody = await response.json()
|
||||
responseBody = JSON.stringify(jsonBody)
|
||||
} else {
|
||||
// For non-JSON responses, get raw text
|
||||
responseBody = await response.text()
|
||||
}
|
||||
const responseBody = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error response if it's JSON
|
||||
try {
|
||||
const errorBody = JSON.parse(responseBody)
|
||||
|
||||
return res.status(response.status).json({
|
||||
status: response.status,
|
||||
error: { message: errorBody?.error || 'Edge function returned an error' },
|
||||
})
|
||||
} catch (parseError) {
|
||||
// If not JSON, return the raw error
|
||||
return res.status(response.status).json({
|
||||
status: response.status,
|
||||
error: { message: responseBody || 'Edge function returned an error' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
const responseHeaders: Record<string, string | string[]> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value
|
||||
const existing = responseHeaders[key]
|
||||
if (existing === undefined) {
|
||||
responseHeaders[key] = value
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value)
|
||||
} else {
|
||||
responseHeaders[key] = [existing, value]
|
||||
}
|
||||
})
|
||||
|
||||
return res.status(response.status).json({
|
||||
return res.status(200).json({
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
body: responseBody,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getEdgeFunctionErrorDocs } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
describe('getEdgeFunctionErrorDocs', () => {
|
||||
it('builds a documentation link from the error code header', () => {
|
||||
expect(
|
||||
getEdgeFunctionErrorDocs({ 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' })
|
||||
).toEqual({
|
||||
code: 'UNAUTHORIZED_INVALID_JWT_FORMAT',
|
||||
href: `${DOCS_URL}/guides/functions/error-codes#unauthorizedinvalidjwtformat`,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same anchor format as documentation headings', () => {
|
||||
expect(getEdgeFunctionErrorDocs({ 'sb-error-code': 'WORKER_RESOURCE_LIMIT' })).toEqual({
|
||||
code: 'WORKER_RESOURCE_LIMIT',
|
||||
href: `${DOCS_URL}/guides/functions/error-codes#workerresourcelimit`,
|
||||
})
|
||||
})
|
||||
|
||||
it('matches the error code header case-insensitively', () => {
|
||||
expect(getEdgeFunctionErrorDocs({ 'Sb-Error-Code': 'BOOT_ERROR' })).toEqual({
|
||||
code: 'BOOT_ERROR',
|
||||
href: `${DOCS_URL}/guides/functions/error-codes#booterror`,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the first value when the header contains multiple values', () => {
|
||||
expect(getEdgeFunctionErrorDocs({ 'sb-error-code': ['WORKER_ERROR', 'BOOT_ERROR'] })).toEqual({
|
||||
code: 'WORKER_ERROR',
|
||||
href: `${DOCS_URL}/guides/functions/error-codes#workererror`,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the link when the error code header is missing', () => {
|
||||
expect(getEdgeFunctionErrorDocs({})).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['', '___'])('omits the link for an unusable error code', (code) => {
|
||||
expect(getEdgeFunctionErrorDocs({ 'sb-error-code': code })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createMocks } from 'node-mocks-http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import handler from '../../../../pages/api/edge-functions/test'
|
||||
|
||||
vi.mock('common', () => ({ IS_PLATFORM: true }))
|
||||
|
||||
const createRequest = (url = 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/test') =>
|
||||
createMocks({
|
||||
method: 'POST',
|
||||
body: {
|
||||
url,
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {},
|
||||
},
|
||||
})
|
||||
|
||||
describe('/api/edge-functions/test', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('preserves unsuccessful edge function responses', async () => {
|
||||
const body = JSON.stringify({
|
||||
code: 'UNAUTHORIZED_NO_AUTH_HEADER',
|
||||
message: 'Missing authorization header',
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(body, {
|
||||
status: 401,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER',
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
const { req, res } = createRequest()
|
||||
|
||||
await handler(req, res)
|
||||
|
||||
expect(res._getStatusCode()).toBe(200)
|
||||
expect(JSON.parse(res._getData())).toEqual({
|
||||
status: 401,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER',
|
||||
},
|
||||
body,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves multiple Set-Cookie headers', async () => {
|
||||
const headers = new Headers()
|
||||
headers.append('set-cookie', 'session=one; Path=/; HttpOnly')
|
||||
headers.append('set-cookie', 'csrf=two; Path=/; SameSite=Lax')
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { headers })))
|
||||
const { req, res } = createRequest()
|
||||
|
||||
await handler(req, res)
|
||||
|
||||
expect(JSON.parse(res._getData())).toEqual({
|
||||
status: 200,
|
||||
headers: {
|
||||
'set-cookie': ['session=one; Path=/; HttpOnly', 'csrf=two; Path=/; SameSite=Lax'],
|
||||
},
|
||||
body: '',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['successful JSON', JSON.stringify({ ok: true }), 'application/json', 200],
|
||||
[
|
||||
'gateway JSON',
|
||||
JSON.stringify({ message: 'Name resolution failed' }),
|
||||
'application/json',
|
||||
503,
|
||||
],
|
||||
['legacy JSON', JSON.stringify({ msg: 'Invalid JWT' }), 'application/json', 503],
|
||||
[
|
||||
'nested JSON',
|
||||
JSON.stringify({ error: { message: 'Function failed' } }),
|
||||
'application/json',
|
||||
503,
|
||||
],
|
||||
['arbitrary JSON', JSON.stringify({ details: ['Function failed'] }), 'application/json', 503],
|
||||
['plain text', 'Bad Gateway', 'text/plain', 503],
|
||||
['malformed JSON', '{"message": invalid json', 'application/json', 503],
|
||||
])('preserves %s bodies without parsing them', async (_name, body, contentType, status) => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: { 'content-type': contentType },
|
||||
})
|
||||
)
|
||||
)
|
||||
const { req, res } = createRequest()
|
||||
|
||||
await handler(req, res)
|
||||
|
||||
expect(res._getStatusCode()).toBe(200)
|
||||
expect(JSON.parse(res._getData())).toEqual({
|
||||
status,
|
||||
headers: { 'content-type': contentType },
|
||||
body,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid URLs without making an upstream request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { req, res } = createRequest('https://example.com/functions/v1/test')
|
||||
|
||||
await handler(req, res)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(res._getStatusCode()).toBe(400)
|
||||
expect(JSON.parse(res._getData())).toEqual({
|
||||
status: 400,
|
||||
error: { message: 'Provided URL is not a valid Supabase edge function URL' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fetch failures as proxy errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Connection refused')))
|
||||
const { req, res } = createRequest()
|
||||
|
||||
await handler(req, res)
|
||||
|
||||
expect(res._getStatusCode()).toBe(500)
|
||||
expect(JSON.parse(res._getData())).toEqual({
|
||||
status: 500,
|
||||
error: { message: 'Connection refused' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
|
||||
const FUNCTION_SLUG = 'error-code-docs'
|
||||
|
||||
test.describe('Edge Functions', () => {
|
||||
test('links an sb-error-code response to its documentation', async ({ page, ref }) => {
|
||||
await page.route(`**/api/v1/projects/${ref}/functions/${FUNCTION_SLUG}`, async (route) => {
|
||||
const timestamp = Date.now()
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
slug: FUNCTION_SLUG,
|
||||
name: FUNCTION_SLUG,
|
||||
version: 1,
|
||||
status: 'ACTIVE',
|
||||
entrypoint_path: `supabase/functions/${FUNCTION_SLUG}/index.ts`,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/edge-functions/test', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 401,
|
||||
headers: { 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' },
|
||||
body: '',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/functions/${FUNCTION_SLUG}`))
|
||||
await page.getByRole('button', { name: 'Test', exact: true }).click()
|
||||
|
||||
const testResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/edge-functions/test') &&
|
||||
response.request().method() === 'POST'
|
||||
)
|
||||
await page.getByRole('button', { name: 'Send Request' }).click()
|
||||
expect(
|
||||
(await testResponse).ok(),
|
||||
'Studio proxy should return a successful response envelope'
|
||||
).toBeTruthy()
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: 'View documentation for UNAUTHORIZED_INVALID_JWT_FORMAT (opens in new tab)',
|
||||
}),
|
||||
'Error response should link to its documentation section'
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://supabase.com/docs/guides/functions/error-codes#unauthorizedinvalidjwtformat'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user