mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
all of it
This commit is contained in:
1 parent
70583bf968
commit
e36233cfe6
14 files changed
+703
-225
No files matched your search
+203
-167
@@ -4,14 +4,14 @@ import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { object, string, boolean } from 'yup'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { FormActions } from 'components/ui/Forms/FormActions'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import { FormFieldWrapper } from 'components/ui/Forms'
|
||||
import { SectionHeader } from 'components/layouts/PageLayout'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
@@ -23,16 +23,27 @@ import {
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CriticalIcon,
|
||||
Form,
|
||||
Form_Shadcn_,
|
||||
Input,
|
||||
Modal,
|
||||
Switch,
|
||||
Toggle,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import CommandRender from '../CommandRender'
|
||||
import { generateCLICommands } from './EdgeFunctionDetails.utils'
|
||||
|
||||
const schema = object({
|
||||
name: string().required('Name is required'),
|
||||
verify_jwt: boolean().required(),
|
||||
})
|
||||
|
||||
const EdgeFunctionDetails = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef, functionSlug } = useParams()
|
||||
@@ -50,7 +61,6 @@ const EdgeFunctionDetails = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const formId = 'edge-function-update-form'
|
||||
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
|
||||
|
||||
const { anonKey } = getAPIKeys(settings)
|
||||
@@ -69,7 +79,24 @@ const EdgeFunctionDetails = () => {
|
||||
anonKey: apiKey,
|
||||
})
|
||||
|
||||
const onUpdateFunction = async (values: any, { resetForm }: any) => {
|
||||
const form = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
verify_jwt: false,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFunction) {
|
||||
form.reset({
|
||||
name: selectedFunction.name,
|
||||
verify_jwt: selectedFunction.verify_jwt,
|
||||
})
|
||||
}
|
||||
}, [selectedFunction])
|
||||
|
||||
const onUpdateFunction = async (values: any) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (selectedFunction === undefined) return console.error('No edge function selected')
|
||||
|
||||
@@ -81,7 +108,6 @@ const EdgeFunctionDetails = () => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetForm({ values, initialValues: values })
|
||||
toast.success(`Successfully updated edge function`)
|
||||
},
|
||||
}
|
||||
@@ -101,173 +127,183 @@ const EdgeFunctionDetails = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 pb-16 pt-3">
|
||||
<Form id={formId} initialValues={{}} onSubmit={onUpdateFunction}>
|
||||
{({ handleReset, values, initialValues, resetForm }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// [Alaister] although this "technically" is breaking the rules of React hooks
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (selectedFunction !== undefined) {
|
||||
const formValues = {
|
||||
name: selectedFunction?.name,
|
||||
verify_jwt: selectedFunction?.verify_jwt,
|
||||
}
|
||||
resetForm({ values: formValues, initialValues: formValues })
|
||||
}
|
||||
}, [selectedFunction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormPanel
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isUpdating}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
helper={
|
||||
!canUpdateEdgeFunction
|
||||
? 'You need additional permissions to update this function'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>Function Details</FormSectionLabel>}>
|
||||
<FormSectionContent loading={selectedFunction === undefined}>
|
||||
<Input id="name" name="name" label="Name" disabled={!canUpdateEdgeFunction} />
|
||||
<Input
|
||||
disabled
|
||||
id="slug"
|
||||
name="slug"
|
||||
label="Slug"
|
||||
value={selectedFunction?.slug}
|
||||
/>
|
||||
<Input disabled copy label="Endpoint URL" value={functionUrl} />
|
||||
<Input disabled label="Region" value="All functions are deployed globally" />
|
||||
<Input
|
||||
disabled
|
||||
label="Created at"
|
||||
value={dayjs(selectedFunction?.created_at ?? 0).format(
|
||||
'dddd, MMMM D, YYYY h:mm A'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
disabled
|
||||
label="Last updated at"
|
||||
value={dayjs(selectedFunction?.updated_at ?? 0).format(
|
||||
'dddd, MMMM D, YYYY h:mm A'
|
||||
)}
|
||||
/>
|
||||
<Input disabled label="Deployments" value={selectedFunction?.version ?? 0} />
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection header={<FormSectionLabel>Function Configuration</FormSectionLabel>}>
|
||||
<FormSectionContent loading={selectedFunction === undefined}>
|
||||
<Toggle
|
||||
id="verify_jwt"
|
||||
name="verify_jwt"
|
||||
<SectionHeader title="Function Configuration" />
|
||||
<div className="mx-auto flex flex-col xl:flex-row gap-8">
|
||||
<div className="flex-1 space-y-6">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormFieldWrapper
|
||||
name="name"
|
||||
control={form.control}
|
||||
label="Name"
|
||||
description="Your slug and endpoint URL will remain the same"
|
||||
>
|
||||
{(field) => (
|
||||
<Input {...field} className="w-full" disabled={!canUpdateEdgeFunction} />
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormFieldWrapper
|
||||
name="verify_jwt"
|
||||
control={form.control}
|
||||
label="Enforce JWT Verification"
|
||||
description="Require a valid JWT in the authorization header when invoking the function"
|
||||
>
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
label="Enforce JWT Verification"
|
||||
descriptionText="Require a valid JWT in the authorization header when invoking the function"
|
||||
/>
|
||||
<div className="space-y-1 border rounded border-default bg-surface-200 px-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm">
|
||||
Import maps are{' '}
|
||||
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
|
||||
{hasImportMap ? 'used' : 'not used'}
|
||||
</span>{' '}
|
||||
for this function
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Import maps allow the use of bare specifiers in functions instead of
|
||||
explicit import URLs
|
||||
</p>
|
||||
<div className="!mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/dependencies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
More about import maps
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button type="default" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isUpdating}
|
||||
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
|
||||
<div
|
||||
className="space-y-6 rounded border bg-surface-100 px-6 py-4 drop-shadow-sm transition-all overflow-hidden"
|
||||
style={{ maxHeight: showInstructions ? 800 : 66 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded border bg-foreground p-2 text-background">
|
||||
<Terminal size={18} strokeWidth={2} />
|
||||
<div
|
||||
className="space-y-6 rounded border bg-surface-100 px-6 py-4 drop-shadow-sm transition-all overflow-hidden"
|
||||
style={{ maxHeight: showInstructions ? 800 : 66 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded border bg-foreground p-2 text-background">
|
||||
<Terminal size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<h4>Command line access</h4>
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
>
|
||||
{showInstructions ? (
|
||||
<Minimize2 size={14} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Maximize2 size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<h4>Command line access</h4>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
|
||||
{showInstructions ? (
|
||||
<Minimize2 size={14} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Maximize2 size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h5 className="text-base">Deployment management</h5>
|
||||
<CommandRender commands={managementCommands} />
|
||||
<h5 className="text-base">Invoke </h5>
|
||||
<CommandRender commands={invokeCommands} />
|
||||
<h5 className="text-base">Secrets management</h5>
|
||||
<CommandRender commands={secretCommands} />
|
||||
</div>
|
||||
|
||||
<h5 className="text-base">Deployment management</h5>
|
||||
<CommandRender commands={managementCommands} />
|
||||
<h5 className="text-base">Invoke </h5>
|
||||
<CommandRender commands={invokeCommands} />
|
||||
<h5 className="text-base">Secrets management</h5>
|
||||
<CommandRender commands={secretCommands} />
|
||||
<div className="!mt-8">
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<CriticalIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
Once your function is deleted, it can no longer be restored
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
Make sure you have made a backup if you want to restore your edge function
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-3">
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
loading={selectedFunction?.id === undefined}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Delete edge function
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="!mt-8">
|
||||
<FormHeader title="Delete Edge Function" description="" />
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<CriticalIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
Once your function is deleted, it can no longer be restored
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
Make sure you have made a backup if you want to restore your edge function
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-3">
|
||||
<ButtonTooltip
|
||||
type="danger"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
loading={selectedFunction?.id === undefined}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateEdgeFunction
|
||||
? 'You need additional permissions to delete edge functions'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete edge function
|
||||
</ButtonTooltip>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
<div className="flex-1 max-w-xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<dl className="grid grid-cols-1 xl:grid-cols-[12rem_1fr] gap-y-6">
|
||||
<dt className="text-sm text-foreground-light">Slug</dt>
|
||||
<dd className="text-sm lg:text-left">{selectedFunction?.slug}</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Endpoint URL</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
<Input
|
||||
className="font-mono input-mono"
|
||||
disabled
|
||||
copy
|
||||
size="small"
|
||||
value={functionUrl}
|
||||
/>
|
||||
</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Region</dt>
|
||||
<dd className="text-sm lg:text-left">All functions are deployed globally</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Created at</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
{dayjs(selectedFunction?.created_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Last updated at</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
{dayjs(selectedFunction?.updated_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Deployments</dt>
|
||||
<dd className="text-sm lg:text-left">{selectedFunction?.version ?? 0}</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Import Maps</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
<p>
|
||||
Import maps are{' '}
|
||||
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
|
||||
{hasImportMap ? 'used' : 'not used'}
|
||||
</span>{' '}
|
||||
for this function
|
||||
</p>
|
||||
<p className="text-foreground-light mt-1">
|
||||
Import maps allow the use of bare specifiers in functions instead of explicit
|
||||
import URLs
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/dependencies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
More about import maps
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Label_Shadcn_ as Label,
|
||||
Select_Shadcn_ as Select,
|
||||
SelectContent_Shadcn_ as SelectContent,
|
||||
SelectItem_Shadcn_ as SelectItem,
|
||||
SelectTrigger_Shadcn_ as SelectTrigger,
|
||||
SelectValue_Shadcn_ as SelectValue,
|
||||
TextArea_Shadcn_ as Textarea,
|
||||
Tabs_Shadcn_ as Tabs,
|
||||
TabsContent_Shadcn_ as TabsContent,
|
||||
TabsList_Shadcn_ as TabsList,
|
||||
TabsTrigger_Shadcn_ as TabsTrigger,
|
||||
Badge,
|
||||
CodeBlock,
|
||||
} from 'ui'
|
||||
import { constructHeaders } from 'data/fetchers'
|
||||
import { useParams } from 'common'
|
||||
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
|
||||
import { getRoleImpersonationJWT } from 'lib/role-impersonation'
|
||||
import { useGetImpersonatedRole } from 'state/role-impersonation-state'
|
||||
import {
|
||||
RoleImpersonationPopover,
|
||||
RoleImpersonationSelector,
|
||||
} from 'components/interfaces/RoleImpersonationSelector'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { prettifyJSON } from 'lib/helpers'
|
||||
|
||||
type ResponseData = {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
|
||||
interface EdgeFunctionTesterProps {
|
||||
url: string
|
||||
anonKey: string
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number]
|
||||
|
||||
export default function EdgeFunctionTester({ url, anonKey }: EdgeFunctionTesterProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [method, setMethod] = useState<HttpMethod>('POST')
|
||||
const [body, setBody] = useState('{ "name": "Functions" }')
|
||||
const [response, setResponse] = useState<ResponseData | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 sendRequest = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setResponse(null)
|
||||
|
||||
let requestBody: any
|
||||
try {
|
||||
requestBody = JSON.parse(body)
|
||||
} catch (e) {
|
||||
throw new Error('Invalid JSON in request body')
|
||||
}
|
||||
|
||||
let testAuthorization: string | undefined
|
||||
const role = getImpersonatedRole()
|
||||
|
||||
console.log('Role impersonation details:', {
|
||||
role,
|
||||
projectRef,
|
||||
hasJwtSecret: !!config?.jwt_secret,
|
||||
hasServiceKey: !!serviceKey?.api_key,
|
||||
hasAccessToken: !!accessToken,
|
||||
})
|
||||
|
||||
if (
|
||||
projectRef !== undefined &&
|
||||
config?.jwt_secret !== undefined &&
|
||||
role !== undefined &&
|
||||
role.type === 'postgrest'
|
||||
) {
|
||||
try {
|
||||
console.log('Generating JWT for role:', {
|
||||
roleDetails: role,
|
||||
type: role.type,
|
||||
})
|
||||
|
||||
const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role)
|
||||
testAuthorization = 'Bearer ' + token
|
||||
console.log('Successfully generated JWT token for role')
|
||||
} catch (err: any) {
|
||||
console.error('Failed to generate JWT:', {
|
||||
error: err.message,
|
||||
roleDetails: role,
|
||||
})
|
||||
// Don't throw, fall back to service key
|
||||
console.log('Falling back to service key')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('serviceKey', serviceKey)
|
||||
|
||||
const headers = await constructHeaders()
|
||||
const res = await fetch('/api/edge-functions/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
method,
|
||||
body: requestBody,
|
||||
headers: {
|
||||
...(accessToken && {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}),
|
||||
'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error?.message || 'Failed to test edge function')
|
||||
}
|
||||
|
||||
setResponse(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-foreground-light mb-2 block text-xs">Method</Label>
|
||||
<Select
|
||||
value={method}
|
||||
onValueChange={(value) => setMethod(value as HttpMethod)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-foreground-light mb-2 block text-xs">Body</Label>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Request body (JSON)"
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RoleImpersonationPopover />
|
||||
<Button type="primary" onClick={sendRequest} loading={isLoading} disabled={isLoading}>
|
||||
Send Request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>Error</Label>
|
||||
<p className="text-sm text-foreground-light">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<div className="mt-4 bg-surface-100 border-t -mx-4 -mb-4">
|
||||
<Tabs defaultValue="body" className="w-full gap-0">
|
||||
<TabsList className="gap-4 pt-2 px-4">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<TabsTrigger className="text-xs" value="body">
|
||||
Body
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="text-xs" value="headers">
|
||||
Headers
|
||||
</TabsTrigger>
|
||||
</div>
|
||||
<Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
|
||||
{response.status}
|
||||
</Badge>
|
||||
</TabsList>
|
||||
<TabsContent value="body" className="mt-0">
|
||||
<CodeBlock
|
||||
language="json"
|
||||
hideLineNumbers
|
||||
className="rounded-md !border-none !px-4 !py-3 max-h-32"
|
||||
value={prettifyJSON(response.body)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="headers" className="mt-0">
|
||||
<CodeBlock
|
||||
language="json"
|
||||
hideLineNumbers
|
||||
className="rounded-md !border-none !px-4 !py-3 max-h-32"
|
||||
value={prettifyJSON(JSON.stringify(response.headers, null, 2))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,36 @@ Deno.serve(async (req: Request) => {
|
||||
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }}
|
||||
);
|
||||
});`,
|
||||
},
|
||||
{
|
||||
name: 'Supabase Database Access',
|
||||
description: 'Example using Supabase client to query your database',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
|
||||
)
|
||||
|
||||
const { data, error } = await supabase.from('countries').select('*')
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ data }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
} catch (err) {
|
||||
return new Response(String(err?.message ?? err), { status: 500 })
|
||||
}
|
||||
})`,
|
||||
},
|
||||
{
|
||||
name: 'Node Built-in API Example',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, type PropsWithChildren } from 'react'
|
||||
import { useEffect, type PropsWithChildren, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
@@ -11,19 +11,90 @@ import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { Code } from 'lucide-react'
|
||||
import { Code, Code2, Play, Send, Terminal, TestTubeDiagonal } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Button, cn, CodeBlock } from 'ui'
|
||||
import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import FunctionsNav from '../../interfaces/Functions/FunctionsNav'
|
||||
import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout'
|
||||
import EdgeFunctionsLayout from '../EdgeFunctionsLayout/EdgeFunctionsLayout'
|
||||
import EdgeFunctionTester from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTester'
|
||||
|
||||
interface FunctionsLayoutProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
const InvokePopover = ({
|
||||
functionName,
|
||||
url,
|
||||
apiKey,
|
||||
}: {
|
||||
functionName: string
|
||||
url: string
|
||||
apiKey: string
|
||||
}) => {
|
||||
return (
|
||||
<Popover_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button type="default" icon={<Terminal size={16} />}>
|
||||
Invoke
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ align="end" className="w-xl w-full max-w-[500px] space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm mb-2">Invoke via CLI</h4>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
className="text-xs !mt-0"
|
||||
value={`curl -L -X POST '${url}' \\
|
||||
-H 'Authorization: Bearer ${apiKey}' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
--data '{"name":"Functions"}'`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm mb-2">Invoke via supabase-js</h4>
|
||||
<CodeBlock
|
||||
language="js"
|
||||
hideLineNumbers
|
||||
className="text-xs !mt-0 leading-3"
|
||||
value={`import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
|
||||
const { data, error } = await supabase.functions.invoke('${functionName}', {
|
||||
body: { name: 'Functions' },
|
||||
})`}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
const TestPopover = ({ url, apiKey }: { url: string; apiKey: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button type="default" size="tiny" icon={<Send size={16} />}>
|
||||
Test
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="w-[500px]" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm mb-4">Send test requests to your edge function</p>
|
||||
<EdgeFunctionTester url={url} anonKey={apiKey} />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutProps>) => {
|
||||
const router = useRouter()
|
||||
const { functionSlug, ref } = useParams()
|
||||
@@ -35,11 +106,17 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
|
||||
error,
|
||||
isError,
|
||||
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef: ref })
|
||||
|
||||
const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*')
|
||||
|
||||
const name = selectedFunction?.name || ''
|
||||
const hasFunctions = (functions ?? []).length > 0
|
||||
const { anonKey } = getAPIKeys(settings)
|
||||
const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]'
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint ?? ''
|
||||
const functionUrl = `${protocol}://${endpoint}/functions/v1/${functionSlug}`
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
@@ -92,42 +169,37 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
|
||||
|
||||
return (
|
||||
<EdgeFunctionsLayout>
|
||||
<ProjectLayout
|
||||
isLoading={isLoading}
|
||||
title={title || 'Edge Functions'}
|
||||
product="Edge Functions"
|
||||
<PageLayout
|
||||
size="full"
|
||||
title={functionSlug ? name : 'Edge Functions'}
|
||||
subtitle={
|
||||
functionSlug
|
||||
? `https://${ref}.functions.supabase.co/${functionSlug}`
|
||||
: 'Write and deploy code without having to manage servers'
|
||||
}
|
||||
breadcrumbs={breadcrumbItems}
|
||||
navigationItems={navigationItems}
|
||||
primaryActions={
|
||||
<div className="flex items-center space-x-2">
|
||||
{isNewAPIDocsEnabled && (
|
||||
<APIDocsButton
|
||||
section={
|
||||
functionSlug !== undefined ? ['edge-functions', functionSlug] : ['edge-functions']
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocsButton href="https://supabase.com/docs/guides/functions" />
|
||||
{functionSlug && (
|
||||
<>
|
||||
<InvokePopover functionName={functionSlug} url={functionUrl} apiKey={apiKey} />
|
||||
<TestPopover url={functionUrl} apiKey={apiKey} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PageLayout
|
||||
size="full"
|
||||
title={functionSlug ? name : 'Edge Functions'}
|
||||
subtitle={
|
||||
functionSlug
|
||||
? `https://${ref}.functions.supabase.co/${functionSlug}`
|
||||
: 'Write and deploy code without having to manage servers'
|
||||
}
|
||||
breadcrumbs={breadcrumbItems}
|
||||
navigationItems={navigationItems}
|
||||
primaryActions={
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${ref}/settings/functions`}>Manage secrets</Link>
|
||||
</Button>
|
||||
{isNewAPIDocsEnabled && (
|
||||
<APIDocsButton
|
||||
section={
|
||||
functionSlug !== undefined
|
||||
? ['edge-functions', functionSlug]
|
||||
: ['edge-functions']
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocsButton href="https://supabase.com/docs/guides/functions" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</ProjectLayout>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</EdgeFunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useParams } from 'common'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import {
|
||||
@@ -47,11 +48,18 @@ export const PageHeader = ({
|
||||
<Fragment key={item.label}>
|
||||
<BreadcrumbItem>
|
||||
{item.href ? (
|
||||
<BreadcrumbLink href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
|
||||
<BreadcrumbLink
|
||||
className="flex items-center gap-2"
|
||||
href={!!ref ? item.href.replace('[ref]', ref) : item.href}
|
||||
>
|
||||
{breadcrumbs.length === 1 && <ChevronLeft size={16} strokeWidth={1.5} />}
|
||||
{item.label}
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPageItem>{item.label}</BreadcrumbPageItem>
|
||||
<BreadcrumbPageItem className="flex items-center gap-2">
|
||||
{breadcrumbs.length === 1 && <ChevronLeft size={16} strokeWidth={1.5} />}
|
||||
{item.label}
|
||||
</BreadcrumbPageItem>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
||||
|
||||
@@ -72,7 +72,7 @@ const PageLayout = ({
|
||||
const { ref } = useParams()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full min-h-full flex flex-col items-stretch [&>*]:w-full">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full mx-auto',
|
||||
@@ -104,7 +104,10 @@ const PageLayout = ({
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={!!ref ? item.href.replace('[ref]', ref) : item.href}
|
||||
className="inline-flex items-center gap-2"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2',
|
||||
router.pathname === item.href && 'text-foreground'
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
@@ -121,9 +124,8 @@ const PageLayout = ({
|
||||
</NavMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content section */}
|
||||
<main>{children}</main>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/ai/onboarding/design',
|
||||
'/ai/sql/complete',
|
||||
'/ai/edge-function/complete',
|
||||
'/edge-functions/test',
|
||||
'/ai/sql/title',
|
||||
'/ai/sql/debug',
|
||||
'/ai/sql/cron',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { constructHeaders, handleError } from 'data/fetchers'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({
|
||||
error: { message: `Method ${req.method} Not Allowed` },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, method, body, headers: customHeaders } = req.body
|
||||
|
||||
console.log('Edge function test request:', {
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
customHeaders,
|
||||
})
|
||||
|
||||
// Convert incoming headers to HeadersInit format
|
||||
const incomingHeaders = Object.entries(req.headers).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (value) acc[key] = Array.isArray(value) ? value.join(', ') : value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
|
||||
// Get auth headers from the request to forward them
|
||||
const headers = await constructHeaders(incomingHeaders)
|
||||
const requestHeaders = {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
}
|
||||
|
||||
// Use the test authorization header if provided, otherwise use the default authorization
|
||||
if (customHeaders['x-test-authorization']) {
|
||||
requestHeaders['Authorization'] = customHeaders['x-test-authorization']
|
||||
console.log('Using test authorization header')
|
||||
}
|
||||
|
||||
console.log('Forwarding request with headers:', requestHeaders)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return handleError(error)
|
||||
}
|
||||
|
||||
console.log('Edge function response status:', response.status)
|
||||
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value
|
||||
})
|
||||
|
||||
const responseBody = await response.text()
|
||||
|
||||
console.log('Edge function response:', {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
body: responseBody,
|
||||
})
|
||||
|
||||
return res.status(response.status).json({
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
body: responseBody,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Edge function test error:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause: error.cause,
|
||||
})
|
||||
return res.status(500).json({
|
||||
error: error.message || 'Failed to test edge function',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import { PageContainer } from 'components/layouts/PageLayout'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => (
|
||||
<PageContainer className="py-8">
|
||||
<div className="px-8">
|
||||
<EdgeFunctionDetails />
|
||||
</PageContainer>
|
||||
</div>
|
||||
)
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
|
||||
@@ -159,7 +159,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
return <NoPermission isFullPage resourceText="access this edge function" />
|
||||
}
|
||||
return (
|
||||
<PageContainer size="full" className="py-8 px-8">
|
||||
<div className="px-8 w-full pt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
@@ -336,7 +336,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,15 +20,14 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
if (selectedFunction === undefined || isLoading) return null
|
||||
|
||||
return (
|
||||
<PageContainer size="full" className="py-8 px-8">
|
||||
<div className="flex-1">
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType={'fn_edge'}
|
||||
filterOverride={{ function_id: selectedFunction.id }}
|
||||
filterPanelClassName="px-0"
|
||||
/>
|
||||
</PageContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,14 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
if (selectedFunction === undefined || isLoading) return null
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex-1">
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType={'functions'}
|
||||
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
|
||||
filterPanelClassName="px-0"
|
||||
/>
|
||||
</PageContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,14 +78,18 @@ const FunctionsPage: NextPageWithLayout = () => {
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
size="medium"
|
||||
size="large"
|
||||
title="Edge Functions"
|
||||
subtitle="Deploy edge functions to handle complex business logic"
|
||||
primaryActions={deployButton}
|
||||
secondaryActions={secondaryActions}
|
||||
>
|
||||
<PageContainer size="medium">
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
<PageContainer size="large">
|
||||
{isLoading && (
|
||||
<div className="pt-8">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DocsButton } from 'components/ui/DocsButton'
|
||||
|
||||
const SecretsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<PageContainer className="py-4">
|
||||
<PageContainer className="py-4" size="large">
|
||||
<EdgeFunctionSecrets />
|
||||
</PageContainer>
|
||||
)
|
||||
@@ -20,6 +20,7 @@ SecretsPage.getLayout = (page) => {
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionsLayout>
|
||||
<PageLayout
|
||||
size="large"
|
||||
title="Edge Function Secrets"
|
||||
subtitle="Manage the secrets for your project's edge functions"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user