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 { useState } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { Badge, Button, Form, FormControl, FormField, Input_Shadcn_ as Input, Label_Shadcn_ as Label, ResizableHandle, ResizablePanel, ResizablePanelGroup, Select_Shadcn_ as Select, SelectContent_Shadcn_ as SelectContent, SelectItem_Shadcn_ as SelectItem, SelectTrigger_Shadcn_ as SelectTrigger, SelectValue_Shadcn_ as SelectValue, Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, Tabs_Shadcn_ as Tabs, TabsContent_Shadcn_ as TabsContent, TabsList_Shadcn_ as TabsList, TabsTrigger_Shadcn_ as TabsTrigger, TextArea_Shadcn_ as Textarea, } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { HTTP_METHODS } from './EdgeFunctionDetails.constants' import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useEdgeFunctionTestMutation } from '@/data/edge-functions/edge-function-test-mutation' import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from '@/lib/constants' import { prettifyJSON } from '@/lib/helpers' import { getRoleImpersonationJWT } from '@/lib/role-impersonation' import { RoleImpersonationStateContextProvider, useGetImpersonatedRoleState, } from '@/state/role-impersonation-state' interface EdgeFunctionTesterSheetProps { visible: boolean onClose: () => void } const FormSchema = z.object({ method: z.enum(HTTP_METHODS), body: z .string() .optional() .transform((str) => str || '{}'), headers: z.array( z.object({ key: z.string(), value: z.string(), }) ), queryParams: z.array( z.object({ key: z.string(), value: z.string(), }) ), }) type FormValues = z.infer export const EdgeFunctionTesterSheet = (props: EdgeFunctionTesterSheetProps) => { const { ref: projectRef } = useParams() // [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users. return ( ) } const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { const { data: org } = useSelectedOrganizationQuery() const { ref: projectRef, functionSlug } = useParams() const getImpersonatedRoleState = useGetImpersonatedRoleState() const [response, setResponse] = useState(null) const [error, setError] = useState(null) const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) const { serviceKey } = getKeys(apiKeys) const { mutate: sendEvent } = useSendEventMutation() const { mutate: testEdgeFunction, isPending } = useEdgeFunctionTestMutation({ onSuccess: (res) => setResponse(res), onError: (err) => { setError(err instanceof Error ? err.message : 'An unknown error occurred') if (err instanceof Error) { const errorWithStatus = err as ErrorWithStatus setResponse({ status: errorWithStatus.cause?.status || 500, headers: {}, body: '', }) } }, }) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint ?? '' const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}` const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { method: 'POST', body: '{ "name": "Functions" }', headers: [{ key: '', value: '' }], queryParams: [{ key: '', value: '' }], }, }) const { method } = form.watch() const { fields: headerFields, append: appendHeader, remove: removeHeader, } = useFieldArray({ control: form.control, name: 'headers', }) const { fields: queryParamFields, append: appendQueryParam, remove: removeQueryParam, } = useFieldArray({ control: form.control, name: 'queryParams', }) const addKeyValuePair = (type: 'headers' | 'queryParams') => { if (type === 'headers') { appendHeader({ key: '', value: '' }) } else { appendQueryParam({ key: '', value: '' }) } } const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => { if (type === 'headers') { removeHeader(index) } else { removeQueryParam(index) } } const onSubmit = async (values: FormValues) => { setError(null) setResponse(null) // Validate that the body is valid JSON try { JSON.parse(JSON.stringify(values.body)) } catch (e) { form.setError('body', { message: 'Must be a valid JSON string' }) return } let testAuthorization: string | undefined const role = getImpersonatedRoleState().role if ( projectRef !== undefined && config?.jwt_secret !== undefined && role !== undefined && role.type === 'postgrest' ) { try { const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role) testAuthorization = 'Bearer ' + token } catch (err: any) { console.error('Failed to generate JWT:', { error: err.message, roleDetails: role, }) } } // Construct custom headers const customHeaders: Record = {} values.headers.forEach(({ key, value }) => { if (key && value) { customHeaders[key] = value } }) // Construct query parameters const queryString = values.queryParams .filter(({ key, value }) => key && value) .map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&') const finalUrl = queryString ? `${url}?${queryString}` : url testEdgeFunction({ url: finalUrl, method: values.method, body: values.body, headers: { ...(accessToken && { Authorization: `Bearer ${accessToken}`, }), 'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`, 'Content-Type': 'application/json', ...customHeaders, }, }) } const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => (
{(type === 'headers' ? headerFields : queryParamFields).map((field, index) => (
( )} /> ( )} />
{(type === 'headers' ? headerFields : queryParamFields).length > 1 && (
))}
) return ( { // react-resizable-panels v4 registers document-level capture-phase pointer // handlers that can interfere with Radix Dialog's outside-interaction detection. // Prevent the sheet from closing when interacting with the resize handle. const target = (e as CustomEvent<{ originalEvent: PointerEvent }>).detail?.originalEvent ?.target as HTMLElement | null if (target?.closest?.('[data-separator]')) { e.preventDefault() } }} onFocusOutside={(e) => { // The v4 Separator explicitly calls .focus() on itself during pointerdown, // which can trigger Radix Dialog's focus-outside detection. const target = e.target as HTMLElement | null if (target?.closest?.('[data-separator]')) { e.preventDefault() } }} > Test {functionSlug}
( )} /> {method !== 'GET' && ( (