From 5508d2cd9da3e32566bc259eb4dde8c09b7f57e2 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 28 Feb 2025 18:07:30 +1000 Subject: [PATCH] Test edge functions (#33728) * page components * page component changes * settings but broken saving * rvert * use sheet for provider * styling * remove things * Some refactoring and fixing, added JSDocs to layouts * Smol refactor * Fix * Update JSDocs * updated scaffolding * update edge functions layout * remove params * single function layout * invocation cleanup * remove vars * Clean up * Spelling * Clean up FormFieldWrappers * One last clean up * test edge function * sheet flag * remove prop * fix merge errors * fix merge errors * update sheet * rmeove import * fix ts * Some clean ups * Fix * Make test response area resizeable * Final clean up --------- Co-authored-by: Joshen Lim --- .../EdgeFunctionDetails.constants.ts | 2 + .../EdgeFunctionDetails.tsx | 2 +- .../EdgeFunctionDetails.types.ts | 11 + .../EdgeFunctionTesterSheet.tsx | 443 ++++++++++++++++++ .../EdgeFunctionDetailsLayout.tsx | 23 +- .../layouts/PageLayout/PageHeader.tsx | 6 +- apps/studio/middleware.ts | 1 + apps/studio/pages/api/edge-functions/test.ts | 112 +++++ .../project/[ref]/advisors/performance.tsx | 4 +- 9 files changed, 594 insertions(+), 10 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx create mode 100644 apps/studio/pages/api/edge-functions/test.ts diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts index edf13395e10..5303cc8bc86 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts @@ -66,3 +66,5 @@ final data = res.data;`, )`, }, ] + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index fdbab04ad27..8803b8cd48b 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -148,7 +148,7 @@ export const EdgeFunctionDetails = () => { diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts new file mode 100644 index 00000000000..a831766cb30 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts @@ -0,0 +1,11 @@ +export type ResponseData = { + status: number + headers: Record + body: string +} + +export type ErrorWithStatus = Error & { + cause?: { + status: number + } +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx new file mode 100644 index 00000000000..e49599c4639 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -0,0 +1,443 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2, Plus, Send, X } from 'lucide-react' +import { useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import * as z from 'zod' + +import { useParams } from 'common' +import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' +import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { constructHeaders } from 'data/fetchers' +import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { prettifyJSON } from 'lib/helpers' +import { getRoleImpersonationJWT } from 'lib/role-impersonation' +import { useGetImpersonatedRole } from 'state/role-impersonation-state' +import { + Badge, + Button, + CodeBlock, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + 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 { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { HTTP_METHODS } from './EdgeFunctionDetails.constants' +import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' + +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 = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { + const { ref: projectRef, functionSlug } = useParams() + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) + const getImpersonatedRole = useGetImpersonatedRole() + const { serviceKey } = getAPIKeys(settings) + + 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) => { + try { + setIsLoading(true) + setError(null) + setResponse(null) + + // Validate that the body is valid JSON + try { + JSON.parse(values.body) + } catch (e) { + form.setError('body', { message: 'Must be a valid JSON string' }) + return + } + + let testAuthorization: string | undefined + const role = getImpersonatedRole() + + 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 = {} + headerFields.forEach(({ key, value }) => { + if (key && value) { + customHeaders[key] = value + } + }) + + // Construct query parameters + const queryString = queryParamFields + .filter(({ key, value }) => key && value) + .map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + + const finalUrl = queryString ? `${url}?${queryString}` : url + + const defaultHeaders = await constructHeaders() + const res = await fetch(`${BASE_PATH}/api/edge-functions/test`, { + method: 'POST', + headers: { + ...defaultHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + 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 data = await res.json() + if (!res.ok) { + throw new Error(data.error?.message || 'Failed to test edge function', { + cause: { status: data.status }, + }) + } + + setResponse(data) + } catch (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: '', + }) + } + } finally { + setIsLoading(false) + } + } + + const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => ( +
+
+ + +
+
+ {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ {(type === 'headers' ? headerFields : queryParamFields).length > 1 && ( +
+
+ ))} +
+
+ ) + + return ( + + + + Test {functionSlug} + + + +
+ + +
+ ( + + + + + + )} + /> + {method !== 'GET' && ( + ( + + +