mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: Move search functionality to Vercel function (#48443)
This commit is contained in:
1 parent
6a99952af5
commit
a76f1f0c9b
8 files changed
+248
-18
No files matched your search
@@ -0,0 +1,5 @@
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'content-type',
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user