diff --git a/apps/docs/app/api/search/cors.ts b/apps/docs/app/api/search/cors.ts new file mode 100644 index 00000000000..329df5ef617 --- /dev/null +++ b/apps/docs/app/api/search/cors.ts @@ -0,0 +1,5 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'content-type', +} diff --git a/apps/docs/app/api/search/embeddings/route.test.ts b/apps/docs/app/api/search/embeddings/route.test.ts new file mode 100644 index 00000000000..8cf06aa5c0b --- /dev/null +++ b/apps/docs/app/api/search/embeddings/route.test.ts @@ -0,0 +1,56 @@ +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { POST } from './route' + +const isFeatureEnabledMock = vi.fn().mockReturnValue(true) +vi.mock('common/enabled-features', () => ({ + isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +function makeRequest(body: unknown) { + return new NextRequest('https://example.com/api/search/embeddings', { + method: 'POST', + body: JSON.stringify(body), + }) +} + +describe('/api/search/embeddings', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 when query is missing', async () => { + const response = await POST(makeRequest({})) + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('forwards the query and feature flag to the search-embeddings function', async () => { + isFeatureEnabledMock.mockReturnValue(false) + fetchMock.mockResolvedValue( + new Response(JSON.stringify([{ id: 1, path: '/guides/test' }]), { status: 200 }) + ) + + const response = await POST(makeRequest({ query: 'realtime' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('/functions/v1/search-embeddings') + expect(JSON.parse(init.body)).toEqual({ query: 'realtime', useAlternateSearchIndex: true }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }]) + }) + + it('propagates the upstream status on error', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'boom' }), { status: 500 })) + + const response = await POST(makeRequest({ query: 'realtime' })) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'boom' }) + }) +}) diff --git a/apps/docs/app/api/search/embeddings/route.ts b/apps/docs/app/api/search/embeddings/route.ts new file mode 100644 index 00000000000..06c6b58bb4a --- /dev/null +++ b/apps/docs/app/api/search/embeddings/route.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/nextjs' +import { type NextRequest } from 'next/server' + +import { corsHeaders } from '../cors' +import { _handleEmbeddingsSearchRequest } from './route.utils' + +export const runtime = 'edge' + +export async function OPTIONS() { + return new Response(null, { headers: corsHeaders }) +} + +export async function POST(request: NextRequest) { + try { + const response = await _handleEmbeddingsSearchRequest(request) + Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value)) + return response + } catch (error) { + console.error('Error handling docs embeddings search request:', error) + Sentry.captureException(error, { tags: { route: 'search-embeddings' } }) + return Response.json( + { error: 'There was an error processing your request' }, + { status: 500, headers: corsHeaders } + ) + } +} diff --git a/apps/docs/app/api/search/embeddings/route.utils.ts b/apps/docs/app/api/search/embeddings/route.utils.ts new file mode 100644 index 00000000000..6b0ee49c213 --- /dev/null +++ b/apps/docs/app/api/search/embeddings/route.utils.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/nextjs' +import { isFeatureEnabled } from 'common/enabled-features' +import { type NextRequest } from 'next/server' + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL + +export async function _handleEmbeddingsSearchRequest(request: NextRequest) { + const { query } = await request.json() + + if (!query || typeof query !== 'string') { + return Response.json({ error: 'Missing query in request data' }, { status: 400 }) + } + + const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') + + const response = await fetch(`${SUPABASE_URL}/functions/v1/search-embeddings`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, useAlternateSearchIndex }), + }) + + const data = await response.json() + + if (!response.ok) { + console.error('Error running docs embeddings search:', data) + Sentry.captureException(new Error(data?.error ?? 'search-embeddings request failed'), { + tags: { route: 'search-embeddings' }, + extra: { query, status: response.status, data }, + }) + } + + return Response.json(data, { status: response.status }) +} diff --git a/apps/docs/app/api/search/fts/route.test.ts b/apps/docs/app/api/search/fts/route.test.ts new file mode 100644 index 00000000000..04d947204c1 --- /dev/null +++ b/apps/docs/app/api/search/fts/route.test.ts @@ -0,0 +1,62 @@ +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { POST } from './route' + +const rpcSpy = vi.fn() +vi.mock('~/lib/supabase', () => ({ + supabase: () => ({ rpc: rpcSpy }), +})) + +const isFeatureEnabledMock = vi.fn().mockReturnValue(true) +vi.mock('common/enabled-features', () => ({ + isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args), +})) + +function makeRequest(body: unknown) { + return new NextRequest('https://example.com/api/search/fts', { + method: 'POST', + body: JSON.stringify(body), + }) +} + +describe('/api/search/fts', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 when query is missing', async () => { + const response = await POST(makeRequest({})) + expect(response.status).toBe(400) + expect(rpcSpy).not.toHaveBeenCalled() + }) + + it('calls docs_search_fts when the full index feature is enabled', async () => { + isFeatureEnabledMock.mockReturnValue(true) + rpcSpy.mockResolvedValue({ data: [{ id: 1, path: '/guides/test' }], error: null }) + + const response = await POST(makeRequest({ query: ' realtime ' })) + + expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts', { query: 'realtime' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }]) + }) + + it('calls docs_search_fts_nimbus when the full index feature is disabled', async () => { + isFeatureEnabledMock.mockReturnValue(false) + rpcSpy.mockResolvedValue({ data: [], error: null }) + + await POST(makeRequest({ query: 'realtime' })) + + expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts_nimbus', { query: 'realtime' }) + }) + + it('returns 500 when the RPC errors', async () => { + rpcSpy.mockResolvedValue({ data: null, error: { message: 'boom' } }) + + const response = await POST(makeRequest({ query: 'realtime' })) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'boom' }) + }) +}) diff --git a/apps/docs/app/api/search/fts/route.ts b/apps/docs/app/api/search/fts/route.ts new file mode 100644 index 00000000000..f9c9dc0dbe1 --- /dev/null +++ b/apps/docs/app/api/search/fts/route.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/nextjs' +import { type NextRequest } from 'next/server' + +import { corsHeaders } from '../cors' +import { _handleFtsSearchRequest } from './route.utils' + +export const runtime = 'edge' + +export async function OPTIONS() { + return new Response(null, { headers: corsHeaders }) +} + +export async function POST(request: NextRequest) { + try { + const response = await _handleFtsSearchRequest(request) + Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value)) + return response + } catch (error) { + console.error('Error handling docs full-text search request:', error) + Sentry.captureException(error, { tags: { route: 'search-fts' } }) + return Response.json( + { error: 'There was an error processing your request' }, + { status: 500, headers: corsHeaders } + ) + } +} diff --git a/apps/docs/app/api/search/fts/route.utils.ts b/apps/docs/app/api/search/fts/route.utils.ts new file mode 100644 index 00000000000..bfc947ef2be --- /dev/null +++ b/apps/docs/app/api/search/fts/route.utils.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/nextjs' +import { supabase } from '~/lib/supabase' +import { isFeatureEnabled } from 'common/enabled-features' +import { type NextRequest } from 'next/server' + +export async function _handleFtsSearchRequest(request: NextRequest) { + const { query } = await request.json() + + if (!query || typeof query !== 'string') { + return Response.json({ error: 'Missing query in request data' }, { status: 400 }) + } + + const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') + const searchFunction = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts' + + const { data, error } = await supabase().rpc(searchFunction, { query: query.trim() }) + + if (error) { + console.error('Error running docs full-text search:', error) + Sentry.captureException(new Error(error.message), { + tags: { route: 'search-fts' }, + extra: { query, searchFunction, error }, + }) + return Response.json({ error: error.message }, { status: 500 }) + } + + return Response.json(data) +} diff --git a/packages/common/hooks/useDocsSearch.ts b/packages/common/hooks/useDocsSearch.ts index b64f14b6f90..631baacef3d 100644 --- a/packages/common/hooks/useDocsSearch.ts +++ b/packages/common/hooks/useDocsSearch.ts @@ -3,13 +3,15 @@ import { compact, debounce, uniqBy } from 'lodash' import { useCallback, useMemo, useReducer, useRef } from 'react' -import { isFeatureEnabled } from '../enabled-features' - const NUMBER_SOURCES = 2 -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL -const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY -const FUNCTIONS_URL = '/functions/v1/' +// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs'). +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' +// Public URL of the docs deployment, which hosts the search API routes. +// Same constant apps/studio already uses for cross-linking to docs (see lib/constants/index.ts). +const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs' +// From inside the docs app itself, call our own routes relatively; from studio/www, call docs directly. +const SEARCH_API_BASE = BASE_PATH === '/docs' ? BASE_PATH : DOCS_URL enum PageType { Markdown = 'markdown', @@ -203,18 +205,9 @@ const useDocsSearch = () => { let sourcesLoaded = 0 - const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') - - const searchEndpoint = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts' - fetch(`${SUPABASE_URL}/rest/v1/rpc/${searchEndpoint}`, { + fetch(`${SEARCH_API_BASE}/api/search/fts`, { method: 'POST', - headers: { - 'content-type': 'application/json', - ...(SUPABASE_ANON_KEY && { - apikey: SUPABASE_ANON_KEY, - authorization: `Bearer ${SUPABASE_ANON_KEY}`, - }), - }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: query.trim() }), }) .then((res) => res.json()) @@ -248,9 +241,10 @@ const useDocsSearch = () => { }) }) - fetch(`${SUPABASE_URL}${FUNCTIONS_URL}search-embeddings`, { + fetch(`${SEARCH_API_BASE}/api/search/embeddings`, { method: 'POST', - body: JSON.stringify({ query, useAlternateSearchIndex }), + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query }), }) .then((response) => response.json()) .then((results) => {