EdgeFunctionTesterSheet.tsx505 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { useParams } from 'common'
5import { Loader2, Plus, Send, X } from 'lucide-react'
6import { useState } from 'react'
7import { useFieldArray, useForm } from 'react-hook-form'
8import {
9 Badge,
10 Button,
11 Form,
12 FormControl,
13 FormField,
14 Input,
15 Label,
16 ResizableHandle,
17 ResizablePanel,
18 ResizablePanelGroup,
19 Select,
20 SelectContent,
21 SelectItem,
22 SelectTrigger,
23 SelectValue,
24 Sheet,
25 SheetContent,
26 SheetFooter,
27 SheetHeader,
28 SheetTitle,
29 Tabs_Shadcn_ as Tabs,
30 TabsContent_Shadcn_ as TabsContent,
31 TabsList_Shadcn_ as TabsList,
32 TabsTrigger_Shadcn_ as TabsTrigger,
33 Textarea,
34} from 'ui'
35import { CodeBlock } from 'ui-patterns/CodeBlock'
36import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
37import * as z from 'zod'
38
39import { HTTP_METHODS } from './EdgeFunctionDetails.constants'
40import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types'
41import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
42import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
43import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
44import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query'
45import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
46import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
47import { useEdgeFunctionTestMutation } from '@/data/edge-functions/edge-function-test-mutation'
48import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
49import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
50import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
51import { IS_PLATFORM } from '@/lib/constants'
52import { prettifyJSON } from '@/lib/helpers'
53import { getRoleImpersonationJWT } from '@/lib/role-impersonation'
54import {
55 RoleImpersonationStateContextProvider,
56 useGetImpersonatedRoleState,
57} from '@/state/role-impersonation-state'
58import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
59import { useShortcut } from '@/state/shortcuts/useShortcut'
60
61interface EdgeFunctionTesterSheetProps {
62 visible: boolean
63 onClose: () => void
64}
65
66const FormSchema = z.object({
67 method: z.enum(HTTP_METHODS),
68 body: z
69 .string()
70 .optional()
71 .transform((str) => str || '{}'),
72 headers: z.array(
73 z.object({
74 key: z.string(),
75 value: z.string(),
76 })
77 ),
78 queryParams: z.array(
79 z.object({
80 key: z.string(),
81 value: z.string(),
82 })
83 ),
84})
85
86type FormValues = z.infer<typeof FormSchema>
87
88export const EdgeFunctionTesterSheet = (props: EdgeFunctionTesterSheetProps) => {
89 const { ref: projectRef } = useParams()
90
91 // [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users.
92 return (
93 <RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}>
94 <EdgeFunctionTesterSheetContent {...props} />
95 </RoleImpersonationStateContextProvider>
96 )
97}
98
99const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => {
100 const { data: org } = useSelectedOrganizationQuery()
101 const { ref: projectRef, functionSlug } = useParams()
102 const getImpersonatedRoleState = useGetImpersonatedRoleState()
103
104 const [response, setResponse] = useState<ResponseData | null>(null)
105 const [error, setError] = useState<string | null>(null)
106
107 const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
108 const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
109 const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
110 const { data: settings } = useProjectSettingsV2Query({ projectRef })
111 const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
112 const { serviceKey } = getKeys(apiKeys)
113
114 const { mutate: sendEvent } = useSendEventMutation()
115 const { mutate: testEdgeFunction, isPending } = useEdgeFunctionTestMutation({
116 onSuccess: (res) => setResponse(res),
117 onError: (err) => {
118 setError(err instanceof Error ? err.message : 'An unknown error occurred')
119 if (err instanceof Error) {
120 const errorWithStatus = err as ErrorWithStatus
121 setResponse({
122 status: errorWithStatus.cause?.status || 500,
123 headers: {},
124 body: '',
125 })
126 }
127 },
128 })
129
130 const protocol = settings?.app_config?.protocol ?? 'https'
131 const endpoint = settings?.app_config?.endpoint ?? ''
132 const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}`
133
134 const form = useForm<FormValues>({
135 resolver: zodResolver(FormSchema as any),
136 defaultValues: {
137 method: 'POST',
138 body: '{ "name": "Functions" }',
139 headers: [{ key: '', value: '' }],
140 queryParams: [{ key: '', value: '' }],
141 },
142 })
143 const { method } = form.watch()
144
145 const {
146 fields: headerFields,
147 append: appendHeader,
148 remove: removeHeader,
149 } = useFieldArray({
150 control: form.control,
151 name: 'headers',
152 })
153
154 const {
155 fields: queryParamFields,
156 append: appendQueryParam,
157 remove: removeQueryParam,
158 } = useFieldArray({
159 control: form.control,
160 name: 'queryParams',
161 })
162
163 const addKeyValuePair = (type: 'headers' | 'queryParams') => {
164 if (type === 'headers') {
165 appendHeader({ key: '', value: '' })
166 } else {
167 appendQueryParam({ key: '', value: '' })
168 }
169 }
170
171 const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => {
172 if (type === 'headers') {
173 removeHeader(index)
174 } else {
175 removeQueryParam(index)
176 }
177 }
178
179 useShortcut(
180 SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST,
181 () => {
182 form.handleSubmit(onSubmit)()
183 },
184 { enabled: visible && !isPending }
185 )
186
187 const onSubmit = async (values: FormValues) => {
188 setError(null)
189 setResponse(null)
190
191 // Validate that the body is valid JSON
192 try {
193 JSON.parse(JSON.stringify(values.body))
194 } catch (e) {
195 form.setError('body', { message: 'Must be a valid JSON string' })
196 return
197 }
198
199 let testAuthorization: string | undefined
200 const role = getImpersonatedRoleState().role
201
202 if (
203 projectRef !== undefined &&
204 config?.jwt_secret !== undefined &&
205 role !== undefined &&
206 role.type === 'postgrest'
207 ) {
208 try {
209 const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role)
210 testAuthorization = 'Bearer ' + token
211 } catch (err: any) {
212 console.error('Failed to generate JWT:', {
213 error: err.message,
214 roleDetails: role,
215 })
216 }
217 }
218
219 // Construct custom headers
220 const customHeaders: Record<string, string> = {}
221 values.headers.forEach(({ key, value }) => {
222 if (key && value) {
223 customHeaders[key] = value
224 }
225 })
226
227 // Construct query parameters
228 const queryString = values.queryParams
229 .filter(({ key, value }) => key && value)
230 .map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
231 .join('&')
232
233 const finalUrl = queryString ? `${url}?${queryString}` : url
234
235 testEdgeFunction({
236 url: finalUrl,
237 method: values.method,
238 body: values.body,
239 headers: {
240 ...(accessToken && {
241 Authorization: `Bearer ${accessToken}`,
242 }),
243 'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`,
244 'Content-Type': 'application/json',
245 ...customHeaders,
246 },
247 })
248 }
249
250 const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => (
251 <div className="space-y-2">
252 <div className="flex items-center justify-between">
253 <Label className="text-foreground text-sm">{label}</Label>
254 <Button
255 type="default"
256 size="tiny"
257 icon={<Plus size={14} />}
258 onClick={() => addKeyValuePair(type)}
259 >
260 Add {label}
261 </Button>
262 </div>
263 <div className="border rounded-md bg-surface-200">
264 {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => (
265 <div key={field.id} className="grid grid-cols-[1fr_1fr_32px] border-b last:border-b-0">
266 <FormField
267 control={form.control}
268 name={`${type}.${index}.key`}
269 render={({ field }) => (
270 <FormControl>
271 <Input
272 {...field}
273 size="tiny"
274 placeholder="Enter key..."
275 disabled={isPending}
276 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"
277 />
278 </FormControl>
279 )}
280 />
281 <FormField
282 control={form.control}
283 name={`${type}.${index}.value`}
284 render={({ field }) => (
285 <FormControl>
286 <Input
287 {...field}
288 size="tiny"
289 placeholder="Enter value..."
290 disabled={isPending}
291 className="h-auto py-2 font-mono rounded-none shadow-none bg-transparent border-none"
292 />
293 </FormControl>
294 )}
295 />
296 <div className="flex items-center justify-center">
297 {(type === 'headers' ? headerFields : queryParamFields).length > 1 && (
298 <Button
299 type="text"
300 size="tiny"
301 icon={<X strokeWidth={1.5} size={14} />}
302 className="w-6 h-6"
303 onClick={() => removeKeyValuePair(index, type)}
304 />
305 )}
306 </div>
307 </div>
308 ))}
309 </div>
310 </div>
311 )
312
313 return (
314 <Sheet open={visible} onOpenChange={onClose}>
315 <SheetContent
316 size="default"
317 hasOverlay={false}
318 className="flex flex-col gap-0 p-0"
319 onPointerDownOutside={(e) => {
320 // react-resizable-panels v4 registers document-level capture-phase pointer
321 // handlers that can interfere with Radix Dialog's outside-interaction detection.
322 // Prevent the sheet from closing when interacting with the resize handle.
323 const target = (e as CustomEvent<{ originalEvent: PointerEvent }>).detail?.originalEvent
324 ?.target as HTMLElement | null
325 if (target?.closest?.('[data-separator]')) {
326 e.preventDefault()
327 }
328 }}
329 onFocusOutside={(e) => {
330 // The v4 Separator explicitly calls .focus() on itself during pointerdown,
331 // which can trigger Radix Dialog's focus-outside detection.
332 const target = e.target as HTMLElement | null
333 if (target?.closest?.('[data-separator]')) {
334 e.preventDefault()
335 }
336 }}
337 >
338 <SheetHeader>
339 <SheetTitle>Test {functionSlug}</SheetTitle>
340 </SheetHeader>
341
342 <Form {...form}>
343 <form
344 onSubmit={form.handleSubmit(onSubmit)}
345 className="flex-1 overflow-y-auto flex flex-col"
346 >
347 <ResizablePanelGroup orientation="vertical">
348 <ResizablePanel>
349 <div className="flex flex-col gap-y-4 p-5 h-full overflow-y-auto">
350 <FormField
351 control={form.control}
352 name="method"
353 render={({ field }) => (
354 <FormItemLayout layout="vertical" label="HTTP Method">
355 <FormControl>
356 <Select
357 value={field.value}
358 onValueChange={field.onChange}
359 disabled={isPending}
360 >
361 <SelectTrigger className="w-full">
362 <SelectValue placeholder="Select method" />
363 </SelectTrigger>
364 <SelectContent>
365 {HTTP_METHODS.map((m) => (
366 <SelectItem key={m} value={m}>
367 {m}
368 </SelectItem>
369 ))}
370 </SelectContent>
371 </Select>
372 </FormControl>
373 </FormItemLayout>
374 )}
375 />
376 {method !== 'GET' && (
377 <FormField
378 control={form.control}
379 name="body"
380 render={({ field }) => (
381 <FormItemLayout layout="vertical" label="Request Body">
382 <FormControl>
383 <Textarea
384 {...field}
385 placeholder="Request body (JSON)"
386 rows={3}
387 disabled={isPending}
388 className="font-mono text-xs"
389 />
390 </FormControl>
391 </FormItemLayout>
392 )}
393 />
394 )}
395
396 {renderKeyValuePairs('headers', 'Headers')}
397 {renderKeyValuePairs('queryParams', 'Query Parameters')}
398 </div>
399 </ResizablePanel>
400 <ResizableHandle withHandle />
401 <ResizablePanel defaultSize="41" minSize="41" maxSize="83">
402 <div className="h-full bg-surface-100 border-t flex-1 flex flex-col overflow-hidden">
403 {response ? (
404 <div className="h-full bg-surface-100 flex flex-col overflow-hidden">
405 {error ? (
406 <>
407 <div className="flex gap-2 items-center p-5 text-sm pb-3">
408 Function responded with
409 <Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
410 {response.status}
411 </Badge>
412 </div>
413 <p className="px-5 text-sm text-foreground-light">{error}</p>
414 </>
415 ) : (
416 <Tabs
417 defaultValue="body"
418 className="h-full flex-1 flex flex-col overflow-hidden"
419 >
420 <TabsList className="gap-4 px-5 pt-2">
421 <div className="flex items-center gap-4 flex-1">
422 <TabsTrigger className="text-sm" value="body">
423 Body
424 </TabsTrigger>
425 <TabsTrigger className="text-sm" value="headers">
426 Headers
427 </TabsTrigger>
428 </div>
429 <Badge
430 variant={response.status >= 400 ? 'destructive' : 'success'}
431 className="-translate-y-1"
432 >
433 {response.status}
434 </Badge>
435 </TabsList>
436 <TabsContent value="body" className="mt-0 flex-1 overflow-auto p-0">
437 <CodeBlock
438 language="json"
439 hideLineNumbers
440 className="rounded-md border-none! px-4! py-3! h-full"
441 value={prettifyJSON(response.body)}
442 />
443 </TabsContent>
444 <TabsContent value="headers" className="mt-0 flex-1 overflow-auto p-0">
445 <CodeBlock
446 language="json"
447 hideLineNumbers
448 className="rounded-md border-none! px-4! py-3! h-full"
449 value={prettifyJSON(JSON.stringify(response.headers, null, 2))}
450 />
451 </TabsContent>
452 </Tabs>
453 )}
454 </div>
455 ) : isPending ? (
456 <div className="h-full flex flex-col items-center justify-center gap-2">
457 <Loader2 size={24} className="text-foreground-muted animate-spin" />
458 <p className="text-sm text-foreground-light">Sending request...</p>
459 </div>
460 ) : (
461 <div className="h-full flex flex-col items-center justify-center gap-2">
462 <Send size={24} className="text-foreground-muted" />
463 <p className="text-sm text-foreground-light">Send your first test request</p>
464 </div>
465 )}
466 </div>
467 </ResizablePanel>
468 </ResizablePanelGroup>
469
470 <SheetFooter className="px-5 py-3 border-t">
471 <div className="flex items-center gap-2">
472 <RoleImpersonationPopover
473 disallowAuthenticatedOption
474 header="Run edge function as a role"
475 />
476 <ShortcutTooltip shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST} side="top">
477 <Button
478 type="primary"
479 htmlType="submit"
480 loading={isPending}
481 disabled={isPending}
482 onClick={() =>
483 sendEvent({
484 action: 'edge_function_test_send_button_clicked',
485 properties: {
486 httpMethod: method,
487 },
488 groups: {
489 project: projectRef ?? 'Unknown',
490 organization: org?.slug ?? 'Unknown',
491 },
492 })
493 }
494 >
495 Send Request
496 </Button>
497 </ShortcutTooltip>
498 </div>
499 </SheetFooter>
500 </form>
501 </Form>
502 </SheetContent>
503 </Sheet>
504 )
505}