EdgeFunctionTesterSheet.tsx505 lines · main
| 1 | // @ts-nocheck |
| 2 | import { zodResolver } from '@hookform/resolvers/zod' |
| 3 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 4 | import { useParams } from 'common' |
| 5 | import { Loader2, Plus, Send, X } from 'lucide-react' |
| 6 | import { useState } from 'react' |
| 7 | import { useFieldArray, useForm } from 'react-hook-form' |
| 8 | import { |
| 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' |
| 35 | import { CodeBlock } from 'ui-patterns/CodeBlock' |
| 36 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 37 | import * as z from 'zod' |
| 38 | |
| 39 | import { HTTP_METHODS } from './EdgeFunctionDetails.constants' |
| 40 | import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' |
| 41 | import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' |
| 42 | import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' |
| 43 | import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' |
| 44 | import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' |
| 45 | import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' |
| 46 | import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' |
| 47 | import { useEdgeFunctionTestMutation } from '@/data/edge-functions/edge-function-test-mutation' |
| 48 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 49 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 50 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 51 | import { IS_PLATFORM } from '@/lib/constants' |
| 52 | import { prettifyJSON } from '@/lib/helpers' |
| 53 | import { getRoleImpersonationJWT } from '@/lib/role-impersonation' |
| 54 | import { |
| 55 | RoleImpersonationStateContextProvider, |
| 56 | useGetImpersonatedRoleState, |
| 57 | } from '@/state/role-impersonation-state' |
| 58 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 59 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 60 | |
| 61 | interface EdgeFunctionTesterSheetProps { |
| 62 | visible: boolean |
| 63 | onClose: () => void |
| 64 | } |
| 65 | |
| 66 | const 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 | |
| 86 | type FormValues = z.infer<typeof FormSchema> |
| 87 | |
| 88 | export 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 | |
| 99 | const 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 | } |