EdgeFunctionTesterSheet.tsx 18 KB

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