| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- // @ts-nocheck
- 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,
- Label,
- ResizableHandle,
- ResizablePanel,
- ResizablePanelGroup,
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- Sheet,
- SheetContent,
- SheetFooter,
- SheetHeader,
- SheetTitle,
- Tabs_Shadcn_ as Tabs,
- TabsContent_Shadcn_ as TabsContent,
- TabsList_Shadcn_ as TabsList,
- TabsTrigger_Shadcn_ as TabsTrigger,
- 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 { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
- 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'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useShortcut } from '@/state/shortcuts/useShortcut'
- 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<typeof FormSchema>
- 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 (
- <RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}>
- <EdgeFunctionTesterSheetContent {...props} />
- </RoleImpersonationStateContextProvider>
- )
- }
- const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => {
- const { data: org } = useSelectedOrganizationQuery()
- const { ref: projectRef, functionSlug } = useParams()
- const getImpersonatedRoleState = useGetImpersonatedRoleState()
- const [response, setResponse] = useState<ResponseData | null>(null)
- const [error, setError] = useState<string | null>(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<FormValues>({
- resolver: zodResolver(FormSchema as any),
- 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)
- }
- }
- useShortcut(
- SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST,
- () => {
- form.handleSubmit(onSubmit)()
- },
- { enabled: visible && !isPending }
- )
- 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<string, string> = {}
- 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) => (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <Label className="text-foreground text-sm">{label}</Label>
- <Button
- type="default"
- size="tiny"
- icon={<Plus size={14} />}
- onClick={() => addKeyValuePair(type)}
- >
- Add {label}
- </Button>
- </div>
- <div className="border rounded-md bg-surface-200">
- {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => (
- <div key={field.id} className="grid grid-cols-[1fr_1fr_32px] border-b last:border-b-0">
- <FormField
- control={form.control}
- name={`${type}.${index}.key`}
- render={({ field }) => (
- <FormControl>
- <Input
- {...field}
- size="tiny"
- placeholder="Enter key..."
- disabled={isPending}
- className="h-auto py-2 font-mono rounded-none shadow-none bg-transparent border-l-0 border-r border-t-0 border-b-0 border-border"
- />
- </FormControl>
- )}
- />
- <FormField
- control={form.control}
- name={`${type}.${index}.value`}
- render={({ field }) => (
- <FormControl>
- <Input
- {...field}
- size="tiny"
- placeholder="Enter value..."
- disabled={isPending}
- className="h-auto py-2 font-mono rounded-none shadow-none bg-transparent border-none"
- />
- </FormControl>
- )}
- />
- <div className="flex items-center justify-center">
- {(type === 'headers' ? headerFields : queryParamFields).length > 1 && (
- <Button
- type="text"
- size="tiny"
- icon={<X strokeWidth={1.5} size={14} />}
- className="w-6 h-6"
- onClick={() => removeKeyValuePair(index, type)}
- />
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
- )
- return (
- <Sheet open={visible} onOpenChange={onClose}>
- <SheetContent
- size="default"
- hasOverlay={false}
- className="flex flex-col gap-0 p-0"
- onPointerDownOutside={(e) => {
- // 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()
- }
- }}
- >
- <SheetHeader>
- <SheetTitle>Test {functionSlug}</SheetTitle>
- </SheetHeader>
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex-1 overflow-y-auto flex flex-col"
- >
- <ResizablePanelGroup orientation="vertical">
- <ResizablePanel>
- <div className="flex flex-col gap-y-4 p-5 h-full overflow-y-auto">
- <FormField
- control={form.control}
- name="method"
- render={({ field }) => (
- <FormItemLayout layout="vertical" label="HTTP Method">
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isPending}
- >
- <SelectTrigger className="w-full">
- <SelectValue placeholder="Select method" />
- </SelectTrigger>
- <SelectContent>
- {HTTP_METHODS.map((m) => (
- <SelectItem key={m} value={m}>
- {m}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormControl>
- </FormItemLayout>
- )}
- />
- {method !== 'GET' && (
- <FormField
- control={form.control}
- name="body"
- render={({ field }) => (
- <FormItemLayout layout="vertical" label="Request Body">
- <FormControl>
- <Textarea
- {...field}
- placeholder="Request body (JSON)"
- rows={3}
- disabled={isPending}
- className="font-mono text-xs"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- )}
- {renderKeyValuePairs('headers', 'Headers')}
- {renderKeyValuePairs('queryParams', 'Query Parameters')}
- </div>
- </ResizablePanel>
- <ResizableHandle withHandle />
- <ResizablePanel defaultSize="41" minSize="41" maxSize="83">
- <div className="h-full bg-surface-100 border-t flex-1 flex flex-col overflow-hidden">
- {response ? (
- <div className="h-full bg-surface-100 flex flex-col overflow-hidden">
- {error ? (
- <>
- <div className="flex gap-2 items-center p-5 text-sm pb-3">
- Function responded with
- <Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
- {response.status}
- </Badge>
- </div>
- <p className="px-5 text-sm text-foreground-light">{error}</p>
- </>
- ) : (
- <Tabs
- defaultValue="body"
- className="h-full flex-1 flex flex-col overflow-hidden"
- >
- <TabsList className="gap-4 px-5 pt-2">
- <div className="flex items-center gap-4 flex-1">
- <TabsTrigger className="text-sm" value="body">
- Body
- </TabsTrigger>
- <TabsTrigger className="text-sm" value="headers">
- Headers
- </TabsTrigger>
- </div>
- <Badge
- variant={response.status >= 400 ? 'destructive' : 'success'}
- className="-translate-y-1"
- >
- {response.status}
- </Badge>
- </TabsList>
- <TabsContent value="body" className="mt-0 flex-1 overflow-auto p-0">
- <CodeBlock
- language="json"
- hideLineNumbers
- className="rounded-md border-none! px-4! py-3! h-full"
- value={prettifyJSON(response.body)}
- />
- </TabsContent>
- <TabsContent value="headers" className="mt-0 flex-1 overflow-auto p-0">
- <CodeBlock
- language="json"
- hideLineNumbers
- className="rounded-md border-none! px-4! py-3! h-full"
- value={prettifyJSON(JSON.stringify(response.headers, null, 2))}
- />
- </TabsContent>
- </Tabs>
- )}
- </div>
- ) : isPending ? (
- <div className="h-full flex flex-col items-center justify-center gap-2">
- <Loader2 size={24} className="text-foreground-muted animate-spin" />
- <p className="text-sm text-foreground-light">Sending request...</p>
- </div>
- ) : (
- <div className="h-full flex flex-col items-center justify-center gap-2">
- <Send size={24} className="text-foreground-muted" />
- <p className="text-sm text-foreground-light">Send your first test request</p>
- </div>
- )}
- </div>
- </ResizablePanel>
- </ResizablePanelGroup>
- <SheetFooter className="px-5 py-3 border-t">
- <div className="flex items-center gap-2">
- <RoleImpersonationPopover
- disallowAuthenticatedOption
- header="Run edge function as a role"
- />
- <ShortcutTooltip shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST} side="top">
- <Button
- type="primary"
- htmlType="submit"
- loading={isPending}
- disabled={isPending}
- onClick={() =>
- sendEvent({
- action: 'edge_function_test_send_button_clicked',
- properties: {
- httpMethod: method,
- },
- groups: {
- project: projectRef ?? 'Unknown',
- organization: org?.slug ?? 'Unknown',
- },
- })
- }
- >
- Send Request
- </Button>
- </ShortcutTooltip>
- </div>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
- }
|