From 9fa99b84b64b627e331f28c0915b156566d56273 Mon Sep 17 00:00:00 2001 From: Brandon Wolfgang Date: Thu, 22 Oct 2026 14:25:02 -0500 Subject: [PATCH] feat: add semantic document search endpoint - Implement POST /search with query and result limit validation - Return matching document chunks and similarity scores - Support configurable result limits from 1 to 20 - Return sanitized validation and search failure responses - Add endpoint tests for successful searches, invalid inputs, and error handling --- src/server.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 27 ++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/server.test.ts b/src/server.test.ts index 006d716..fd530dc 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -296,4 +296,97 @@ describe("API endpoints", () => { await app.close(); } }); + + it("POST /search returns matching results", async () => { + const expectedResults = [ + { + documentId: "1", + title: "RAG smoke test", + content: "Relevant knowledge base content.", + chunkIndex: 0, + similarity: 0.92, + }, + ]; + + const app = buildApp( + undefined, + undefined, + undefined, + async (query, limit) => { + assert.equal(query, "knowledge base search"); + assert.equal(limit, 3); + return expectedResults; + }, + ); + + try { + const response = await app.inject({ + method: "POST", + url: "/search", + payload: { + query: "knowledge base search", + limit: 3, + }, + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { results: expectedResults }); + } finally { + await app.close(); + } + }); + + it("POST /search rejects invalid input", async () => { + const app = buildApp(undefined, undefined, undefined, async () => { + throw new Error("Search should not run"); + }); + + try { + for (const payload of [ + { query: " " }, + { query: "valid query", limit: 0 }, + { query: "valid query", limit: 21 }, + { query: "valid query", limit: 1.5 }, + { query: 123 }, + ]) { + const response = await app.inject({ + method: "POST", + url: "/search", + payload, + }); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.json(), { + error: "Invalid search request", + }); + } + } finally { + await app.close(); + } + }); + + it("POST /search returns 503 when search fails", async () => { + const app = buildApp(undefined, undefined, undefined, async () => { + throw new Error("Internal database connection details"); + }); + + try { + const response = await app.inject({ + method: "POST", + url: "/search", + payload: { query: "knowledge base search" }, + }); + + assert.equal(response.statusCode, 503); + assert.deepEqual(response.json(), { + error: "Document search failed", + }); + assert.equal( + response.body.includes("Internal database connection details"), + false, + ); + } finally { + await app.close(); + } + }); }); diff --git a/src/server.ts b/src/server.ts index 00601c6..6da0b83 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,10 +15,16 @@ const documentSchema = z.object({ content: z.string().trim().min(1).max(100_000), }); +const searchSchema = z.object({ + query: z.string().trim().min(1).max(2_000), + limit: z.number().int().min(1).max(20).optional(), +}); + export const buildApp = ( generate = generateResponse, checkHealth = checkOllamaHealth, ingest = ingestDocumentDefault, + search = searchDocumentsDefault, ) => { const app = Fastify({ logger: true, @@ -126,6 +132,27 @@ export const buildApp = ( } }); + app.post("/search", async (request, reply) => { + const result = searchSchema.safeParse(request.body); + + if (!result.success) { + return reply.code(400).send({ + error: "Invalid search request", + }); + } + + try { + const results = await search(result.data.query, result.data.limit); + return { results }; + } catch (error) { + request.log.error({ err: error }, "Document search failed"); + + return reply.code(503).send({ + error: "Document search failed", + }); + } + }); + app.get("/test-error", async () => { throw new Error("Sensitive internal detail"); });