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) => ( + + + {label} + } + onClick={() => addKeyValuePair(type)} + > + Add {label} + + + + {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => ( + + ( + + + + )} + /> + ( + + + + )} + /> + + {(type === 'headers' ? headerFields : queryParamFields).length > 1 && ( + } + className="w-6 h-6" + onClick={() => removeKeyValuePair(index, type)} + /> + )} + + + ))} + + + ) + + return ( + + + + Test {functionSlug} + + + + + + + + ( + + + + + + + + {HTTP_METHODS.map((m) => ( + + {m} + + ))} + + + + + )} + /> + {method !== 'GET' && ( + ( + + + + + + )} + /> + )} + + {renderKeyValuePairs('headers', 'Headers')} + {renderKeyValuePairs('queryParams', 'Query Parameters')} + + + + + + {response ? ( + + {error ? ( + <> + + Function responded with + = 400 ? 'destructive' : 'success'}> + {response.status} + + + {error} + > + ) : ( + + + + + Body + + + Headers + + + = 400 ? 'destructive' : 'success'} + className="-translate-y-1" + > + {response.status} + + + + + + + + + + )} + + ) : isLoading ? ( + + + Sending request... + + ) : ( + + + Send your first test request + + )} + + + + + + + + + Send Request + + + + + + + + ) +} diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index 4ec3562d33d..5a151f0db34 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -1,17 +1,22 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, type PropsWithChildren } from 'react' +import { Send } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useState, type PropsWithChildren } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { EdgeFunctionTesterSheet } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import APIDocsButton from 'components/ui/APIDocsButton' import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { withAuth } from 'hooks/misc/withAuth' -import { useRouter } from 'next/router' -import { toast } from 'sonner' +import { useFlag } from 'hooks/ui/useFlag' +import { Button } from 'ui' import ProjectLayout from '../ProjectLayout/ProjectLayout' import EdgeFunctionsLayout from './EdgeFunctionsLayout' @@ -25,22 +30,28 @@ const EdgeFunctionDetailsLayout = ({ }: PropsWithChildren) => { const router = useRouter() const { functionSlug, ref } = useParams() + const edgeFunctionCreate = useFlag('edgeFunctionCreate') const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*') + const [isOpen, setIsOpen] = useState(false) + const { data: selectedFunction, error, isError, } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const name = selectedFunction?.name || '' + const breadcrumbItems = [ { label: 'Edge Functions', href: `/project/${ref}/functions`, }, ] + const navigationItems = functionSlug ? [ { @@ -101,10 +112,16 @@ const EdgeFunctionDetailsLayout = ({ /> )} + {edgeFunctionCreate && !!functionSlug && ( + } onClick={() => setIsOpen(true)}> + Test + + )} } > {children} + setIsOpen(false)} /> ) diff --git a/apps/studio/components/layouts/PageLayout/PageHeader.tsx b/apps/studio/components/layouts/PageLayout/PageHeader.tsx index 5c0c55bbc26..f32b0966888 100644 --- a/apps/studio/components/layouts/PageLayout/PageHeader.tsx +++ b/apps/studio/components/layouts/PageLayout/PageHeader.tsx @@ -1,8 +1,8 @@ -import { useParams } from 'common' import { ChevronLeft } from 'lucide-react' +import Link from 'next/link' import { Fragment, ReactNode } from 'react' -import Link from 'next/link' +import { useParams } from 'common' import { cn } from 'ui' import { Breadcrumb, @@ -27,7 +27,6 @@ interface PageHeaderProps { secondaryActions?: ReactNode className?: string isCompact?: boolean - pageMeta?: ReactNode } export const PageHeader = ({ @@ -39,7 +38,6 @@ export const PageHeader = ({ secondaryActions, className, isCompact = false, - pageMeta, }: PageHeaderProps) => { const { ref } = useParams() diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 0a8ac7f4e54..8256a9563cd 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -19,6 +19,7 @@ const HOSTED_SUPPORTED_API_URLS = [ '/ai/docs', '/get-ip-address', '/get-utc-time', + '/edge-functions/test', ] export function middleware(request: NextRequest) { diff --git a/apps/studio/pages/api/edge-functions/test.ts b/apps/studio/pages/api/edge-functions/test.ts new file mode 100644 index 00000000000..2d194366de4 --- /dev/null +++ b/apps/studio/pages/api/edge-functions/test.ts @@ -0,0 +1,112 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + return new Response( + JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), + { + status: 405, + headers: { 'Content-Type': 'application/json', Allow: 'POST' }, + } + ) + } +} + +async function handlePost(req: NextApiRequest, res: NextApiResponse) { + try { + const { url, method, body: requestBody, headers: customHeaders } = req.body + + // Remove any undefined or null values from custom headers + const sanitizedCustomHeaders = Object.entries(customHeaders || {}).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && value !== '') { + acc[key] = value as string + } + return acc + }, + {} as Record + ) + + // Only use custom headers and ensure Content-Type is set + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...sanitizedCustomHeaders, + } + + // Use the test authorization header if provided + if (sanitizedCustomHeaders['x-test-authorization']) { + requestHeaders['Authorization'] = sanitizedCustomHeaders['x-test-authorization'] + // Remove the x-test-authorization header as we've moved it to Authorization + delete requestHeaders['x-test-authorization'] + } + + // Prepare the request body based on method and Content-Type + let finalBody = undefined + if (method !== 'GET' && method !== 'HEAD') { + if (requestHeaders['Content-Type'] === 'application/json') { + finalBody = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody) + } else { + finalBody = requestBody + } + } + + const response = await fetch(url, { + method, + headers: requestHeaders, + body: finalBody, + }) + + // Handle non-JSON responses + let responseBody: string + const contentType = response.headers.get('content-type') + if (contentType?.includes('application/json')) { + // If JSON, parse and stringify to ensure it's valid JSON + const jsonBody = await response.json() + responseBody = JSON.stringify(jsonBody) + } else { + // For non-JSON responses, get raw text + responseBody = await response.text() + } + + if (!response.ok) { + // Try to parse error response if it's JSON + try { + const errorBody = JSON.parse(responseBody) + + return res.status(response.status).json({ + status: response.status, + error: { message: errorBody?.error || 'Edge function returned an error' }, + }) + } catch (parseError) { + // If not JSON, return the raw error + return res.status(response.status).json({ + status: response.status, + error: { message: responseBody || 'Edge function returned an error' }, + }) + } + } + + const responseHeaders: Record = {} + response.headers.forEach((value, key) => { + responseHeaders[key] = value + }) + + return res.status(response.status).json({ + status: response.status, + headers: responseHeaders, + body: responseBody, + }) + } catch (error: any) { + return res.status(500).json({ + status: 500, + error: { + message: error.message || 'Failed to test edge function', + }, + }) + } +} diff --git a/apps/studio/pages/project/[ref]/advisors/performance.tsx b/apps/studio/pages/project/[ref]/advisors/performance.tsx index f9ad3a6ecca..083354b267c 100644 --- a/apps/studio/pages/project/[ref]/advisors/performance.tsx +++ b/apps/studio/pages/project/[ref]/advisors/performance.tsx @@ -8,16 +8,16 @@ import LinterDataGrid from 'components/interfaces/Linter/LinterDataGrid' import LinterFilters from 'components/interfaces/Linter/LinterFilters' import LinterPageFooter from 'components/interfaces/Linter/LinterPageFooter' import AdvisorsLayout from 'components/layouts/AdvisorsLayout/AdvisorsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { FormHeader } from 'components/ui/Forms/FormHeader' import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' import { LoadingLine } from 'ui' -import DefaultLayout from 'components/layouts/DefaultLayout' const ProjectLints: NextPageWithLayout = () => { const project = useSelectedProject() - const { ref, preset, id } = useParams() + const { preset, id } = useParams() // need to maintain a list of filters for each tab const [filters, setFilters] = useState<{ level: LINTER_LEVELS; filters: string[] }[]>([
{error}
Sending request...
Send your first test request