diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index 60d3e605c62..d7f58f0d76e 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -9,10 +9,10 @@ import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonatio 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 { useEdgeFunctionTestMutation } from 'data/edge-functions/edge-function-test-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { IS_PLATFORM } from 'lib/constants' import { prettifyJSON } from 'lib/helpers' import { getRoleImpersonationJWT } from 'lib/role-impersonation' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -76,19 +76,34 @@ const FormSchema = z.object({ type FormValues = z.infer export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { - const { ref: projectRef, functionSlug } = useParams() - const { mutate: sendEvent } = useSendEventMutation() const org = useSelectedOrganization() + const { ref: projectRef, functionSlug } = useParams() + const getImpersonatedRoleState = useGetImpersonatedRoleState() + 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 getImpersonatedRoleState = useGetImpersonatedRoleState() const { serviceKey } = getAPIKeys(settings) + const { mutate: sendEvent } = useSendEventMutation() + const { mutate: testEdgeFunction, isLoading } = useEdgeFunctionTestMutation({ + onSuccess: (res) => setResponse(res), + onError: (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: '', + }) + } + }, + }) + const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint ?? '' const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}` @@ -139,98 +154,66 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester } const onSubmit = async (values: FormValues) => { + setError(null) + setResponse(null) + + // Validate that the body is valid JSON 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 = getImpersonatedRoleState().role - - 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) + JSON.parse(values.body) + } catch (e) { + form.setError('body', { message: 'Must be a valid JSON string' }) + return } + + let testAuthorization: string | undefined + const role = getImpersonatedRoleState().role + + 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 + + testEdgeFunction({ + 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 renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => ( diff --git a/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx b/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx index a1a05f2010e..3ba765b5eab 100644 --- a/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx @@ -1,3 +1,4 @@ +import { fetchHandler } from 'data/fetchers' import { BASE_PATH } from 'lib/constants' import { useCallback, useEffect, useState } from 'react' import { Button } from 'ui' @@ -9,7 +10,7 @@ const CLOCK_SKEW_CHECK_INTERVAL = 30 * 60 * 1000 const isClockSkewed = async () => { try { - const response = await fetch(`${BASE_PATH}/api/get-utc-time`) + const response = await fetchHandler(`${BASE_PATH}/api/get-utc-time`) const data = await response.json() // The received time is in UTC timezone, add Z at the end to make JS understand that const serverTime = new Date(data.utcTime).getTime() diff --git a/apps/studio/data/ai/check-api-key-query.ts b/apps/studio/data/ai/check-api-key-query.ts index 44aeaf272d2..01c78a9af6a 100644 --- a/apps/studio/data/ai/check-api-key-query.ts +++ b/apps/studio/data/ai/check-api-key-query.ts @@ -1,6 +1,6 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { constructHeaders } from 'data/fetchers' +import { constructHeaders, fetchHandler } from 'data/fetchers' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { ResponseError } from 'types' import { aiKeys } from './keys' @@ -10,7 +10,10 @@ import { aiKeys } from './keys' export async function checkOpenAIKey(signal?: AbortSignal) { const headers = await constructHeaders() - const response = await fetch(`${BASE_PATH}/api/ai/sql/check-api-key`, { headers, signal }) + const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/check-api-key`, { + headers, + signal, + }) let body: any try { diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts index d8380152771..6b6868698f4 100644 --- a/apps/studio/data/ai/sql-title-mutation.ts +++ b/apps/studio/data/ai/sql-title-mutation.ts @@ -1,7 +1,7 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { toast } from 'sonner' -import { constructHeaders } from 'data/fetchers' +import { constructHeaders, fetchHandler } from 'data/fetchers' import { BASE_PATH } from 'lib/constants' import { ResponseError } from 'types' @@ -16,7 +16,7 @@ export type SqlTitleGenerateVariables = { export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) { const headers = await constructHeaders({ 'Content-Type': 'application/json' }) - const response = await fetch(`${BASE_PATH}/api/ai/sql/title`, { + const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/title`, { headers, method: 'POST', body: JSON.stringify({ diff --git a/apps/studio/data/custom-domains/check-cname-mutation.ts b/apps/studio/data/custom-domains/check-cname-mutation.ts index 0ef8604ae32..97c59858cb5 100644 --- a/apps/studio/data/custom-domains/check-cname-mutation.ts +++ b/apps/studio/data/custom-domains/check-cname-mutation.ts @@ -1,4 +1,5 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { fetchHandler } from 'data/fetchers' import { toast } from 'sonner' import type { ResponseError } from 'types' @@ -21,7 +22,7 @@ export type CheckCNAMERecordResponse = { // [Joshen] Should tally with https://github.com/supabase/cli/blob/63790a1bd43bee06f82c4f510e709925526a4daa/internal/utils/api.go#L98 export async function checkCNAMERecord({ domain }: CheckCNAMERecordVariables) { - const res = await fetch(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, { + const res = await fetchHandler(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, { method: 'GET', headers: { accept: 'application/dns-json' }, }) diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts index 2086fbaff2a..af35720db6e 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -1,5 +1,5 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { constructHeaders, handleError } from 'data/fetchers' +import { constructHeaders, fetchHandler, handleError } from 'data/fetchers' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { ResponseError } from 'types' import { edgeFunctionsKeys } from './keys' @@ -32,7 +32,7 @@ export async function getEdgeFunctionBody( }) // Send to our API for processing (the API will handle the fetch from v1 endpoint) - const parseResponse = await fetch(`${BASE_PATH}/api/edge-functions/body`, { + const parseResponse = await fetchHandler(`${BASE_PATH}/api/edge-functions/body`, { method: 'POST', body: JSON.stringify({ projectRef, slug }), headers, diff --git a/apps/studio/data/edge-functions/edge-function-test-mutation.ts b/apps/studio/data/edge-functions/edge-function-test-mutation.ts new file mode 100644 index 00000000000..1b0b202b8c2 --- /dev/null +++ b/apps/studio/data/edge-functions/edge-function-test-mutation.ts @@ -0,0 +1,71 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { ResponseData } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types' +import { constructHeaders, fetchHandler } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' +import { ResponseError } from 'types' + +export type EdgeFunctionTestResponse = { + title: string + description: string +} + +export type EdgeFunctionTestVariables = { + url: string + method: string + body: string + headers: { [key: string]: string } +} + +export async function testEdgeFunction({ url, method, body, headers }: EdgeFunctionTestVariables) { + const defaultHeaders = await constructHeaders() + + const response = await fetchHandler(`${BASE_PATH}/api/edge-functions/test`, { + method: 'POST', + headers: { ...defaultHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, method, body, headers }), + }) + + let data: any + + try { + data = await response.json() + } catch {} + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to test edge function', { + cause: { status: data.status }, + }) + } + + return data as ResponseData +} + +type EdgeFunctionTestData = Awaited> + +export const useEdgeFunctionTestMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => testEdgeFunction(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to test edge function: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index a834b8a73ba..beec442de72 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -1,19 +1,28 @@ import * as Sentry from '@sentry/nextjs' +import createClient from 'openapi-fetch' + +import { IS_PLATFORM } from 'common' import { API_URL } from 'lib/constants' import { getAccessToken } from 'lib/gotrue' import { uuidv4 } from 'lib/helpers' -import createClient from 'openapi-fetch' import { ResponseError } from 'types' import type { paths } from './api' // generated from openapi-typescript -import { IS_PLATFORM } from 'common' -const DEFAULT_HEADERS = { - Accept: 'application/json', +const DEFAULT_HEADERS = { Accept: 'application/json' } + +export const fetchHandler: typeof fetch = async (input, init) => { + try { + return await fetch(input, init) + } catch (err: any) { + if (err instanceof TypeError && err.message === 'Failed to fetch') { + throw new Error('Unable to reach the server. Please check your network or try again later.') + } + throw err + } } -// This file will eventually replace what we currently have in lib/fetchWrapper, but will be currently unused until we get to that refactor - const client = createClient({ + fetch: fetchHandler, // [Joshen] Just FYI, the replace is temporary until we update env vars API_URL to remove /platform or /v1 - should just be the base URL baseUrl: API_URL?.replace('/platform', ''), referrerPolicy: 'no-referrer-when-downgrade', diff --git a/apps/studio/data/misc/cli-release-version-query.ts b/apps/studio/data/misc/cli-release-version-query.ts index 3394ca34054..098e5eeda41 100644 --- a/apps/studio/data/misc/cli-release-version-query.ts +++ b/apps/studio/data/misc/cli-release-version-query.ts @@ -1,12 +1,15 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { fetchHandler } from 'data/fetchers' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import type { ResponseError } from 'types' import { miscKeys } from './keys' export async function getCLIReleaseVersion() { try { - const data = await fetch(`${BASE_PATH}/api/cli-release-version`).then((res) => res.json()) + const data = await fetchHandler(`${BASE_PATH}/api/cli-release-version`).then((res) => + res.json() + ) return data as { current?: string; latest?: string; beta?: string; published_at?: string } } catch (error) { throw error diff --git a/apps/studio/data/misc/get-default-region-query.ts b/apps/studio/data/misc/get-default-region-query.ts index d5e5a81ce10..9a0e65e9501 100644 --- a/apps/studio/data/misc/get-default-region-query.ts +++ b/apps/studio/data/misc/get-default-region-query.ts @@ -1,15 +1,16 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import type { ResponseError } from 'types' -import { miscKeys } from './keys' import { COUNTRY_LAT_LON } from 'components/interfaces/ProjectCreation/ProjectCreation.constants' -import { getDistanceLatLonKM } from 'lib/helpers' import { AWS_REGIONS_COORDINATES, FLY_REGIONS_COORDINATES, } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' +import { fetchHandler } from 'data/fetchers' +import { getDistanceLatLonKM } from 'lib/helpers' import type { CloudProvider } from 'shared-data' import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' +import type { ResponseError } from 'types' +import { miscKeys } from './keys' const RESTRICTED_POOL = [ 'EAST_US_2', @@ -32,7 +33,9 @@ export async function getDefaultRegionOption({ if (!cloudProvider) throw new Error('Cloud provider is required') try { - const data = await fetch('https://www.cloudflare.com/cdn-cgi/trace').then((res) => res.text()) + const data = await fetchHandler('https://www.cloudflare.com/cdn-cgi/trace').then((res) => + res.text() + ) const locationCode: keyof typeof COUNTRY_LAT_LON = Object.fromEntries( data.split('\n').map((item) => item.split('=')) )['loc'] diff --git a/apps/studio/data/misc/user-ip-address-query.ts b/apps/studio/data/misc/user-ip-address-query.ts index 3e169ce351c..0dcc15e98db 100644 --- a/apps/studio/data/misc/user-ip-address-query.ts +++ b/apps/studio/data/misc/user-ip-address-query.ts @@ -1,12 +1,13 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { fetchHandler } from 'data/fetchers' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import type { ResponseError } from 'types' import { miscKeys } from './keys' export async function getUserIPAddress() { try { - const data = await fetch(`${BASE_PATH}/api/get-ip-address`).then((res) => res.json()) + const data = await fetchHandler(`${BASE_PATH}/api/get-ip-address`).then((res) => res.json()) return data.ipAddress } catch (error) { throw error diff --git a/apps/studio/lib/integration-utils.ts b/apps/studio/lib/integration-utils.ts index 1e9cd740db6..44f176fb3bd 100644 --- a/apps/studio/lib/integration-utils.ts +++ b/apps/studio/lib/integration-utils.ts @@ -1,9 +1,10 @@ +import { fetchHandler } from 'data/fetchers' import type { Integration } from 'data/integrations/integrations.types' import { ResponseError, type SupaResponse } from 'types' import { isResponseOk } from './common/fetch' async function fetchGitHub(url: string, responseJson = true): Promise> { - const response = await fetch(url) + const response = await fetchHandler(url) if (!response.ok) { return { error: new ResponseError(response.statusText, response.status),