From cd6203fd94df3757e6601ca2a89a58398c6a7283 Mon Sep 17 00:00:00 2001 From: David-patrick-chuks Date: Tue, 26 May 2026 16:51:38 +0100 Subject: [PATCH] feat: add snippet search API --- app/api/snippets/route.ts | 73 +++++++++++++++++++++----- app/api/snippets/snippet.repository.ts | 68 ++++++++++++++++++++++++ app/api/snippets/snippet.service.ts | 16 +++++- lib/snippet.service.test.ts | 37 +++++++++++++ scripts/add-snippet-search-indexes.sql | 14 +++++ scripts/init-db.sql | 14 +++++ 6 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 scripts/add-snippet-search-indexes.sql diff --git a/app/api/snippets/route.ts b/app/api/snippets/route.ts index 651e1e2..b160871 100644 --- a/app/api/snippets/route.ts +++ b/app/api/snippets/route.ts @@ -4,6 +4,7 @@ import { SnippetRepository } from "./snippet.repository"; import { OwnershipMiddleware } from "./ownership.middleware"; import { ZodError } from "zod"; import { rateLimit } from "@/lib/rateLimiter"; +import { SearchSnippetsOptions } from "./snippet.repository"; // Default pagination settings const DEFAULT_LIMIT = 20; @@ -15,21 +16,67 @@ const RATE_LIMIT_MAX_REQUESTS = 10; const repository = new SnippetRepository(); const service = new SnippetService(repository); +function parseBoundedInteger( + value: string | null, + fallback: number, + min: number, + max: number, +) { + const parsed = Number.parseInt(value ?? "", 10); + + if (Number.isNaN(parsed)) { + return fallback; + } + + return Math.min(Math.max(parsed, min), max); +} + +function parseSearchOptions(req: NextRequest): SearchSnippetsOptions { + const { searchParams } = new URL(req.url); + const rawTags = searchParams.get("tags"); + + return { + limit: parseBoundedInteger(searchParams.get("limit"), DEFAULT_LIMIT, 1, MAX_LIMIT), + offset: parseBoundedInteger(searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER), + title: searchParams.get("title")?.trim() || undefined, + language: searchParams.get("language")?.trim() || undefined, + keyword: searchParams.get("keyword")?.trim() || undefined, + tags: + rawTags + ?.split(",") + .map((tag) => tag.trim()) + .filter(Boolean) || undefined, + }; +} + +function hasSearchFilters(options: SearchSnippetsOptions) { + return Boolean( + options.title || + options.language || + options.keyword || + (options.tags && options.tags.length > 0), + ); +} + export async function GET(req: NextRequest) { try { - const { searchParams } = new URL(req.url); - - // Parse pagination parameters with validation - const limit = Math.min( - Math.max(parseInt(searchParams.get("limit") || String(DEFAULT_LIMIT), 10), - MAX_LIMIT - ); - const offset = Math.max(parseInt(searchParams.get("offset") || "0", 10), 0); + const options = parseSearchOptions(req); + const result = hasSearchFilters(options) + ? await service.searchSnippets(options) + : await service.getAllSnippets({ + limit: options.limit, + offset: options.offset, + }); - // Handle backward compatibility: if no pagination params, return all (first page) - const result = await service.getAllSnippets({ limit, offset }); - - return NextResponse.json(result); + return NextResponse.json({ + ...result, + filters: { + title: options.title ?? null, + language: options.language ?? null, + tags: options.tags ?? [], + keyword: options.keyword ?? null, + }, + }); } catch (error) { console.error("[API] Error fetching snippets:", error); return NextResponse.json( @@ -91,4 +138,4 @@ export async function POST(req: NextRequest) { { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/api/snippets/snippet.repository.ts b/app/api/snippets/snippet.repository.ts index e2e6acd..0f608fb 100644 --- a/app/api/snippets/snippet.repository.ts +++ b/app/api/snippets/snippet.repository.ts @@ -8,6 +8,13 @@ export interface PaginationOptions { offset: number; } +export interface SearchSnippetsOptions extends PaginationOptions { + title?: string; + language?: string; + tags?: string[]; + keyword?: string; +} + // Paginated result interface export interface PaginatedResult { data: T[]; @@ -51,6 +58,67 @@ export class SnippetRepository { }; } + async search(options: SearchSnippetsOptions) { + const limit = options.limit; + const offset = options.offset; + const title = options.title?.trim() || null; + const titlePattern = title ? `%${title}%` : null; + const language = options.language?.trim() || null; + const keyword = options.keyword?.trim() || null; + const tags = options.tags?.length ? options.tags : null; + const tagsJson = tags ? JSON.stringify(tags) : null; + + const countResult = await this.sql` + SELECT COUNT(*) AS total + FROM snippets + WHERE (${title}::text IS NULL OR title ILIKE ${titlePattern}) + AND (${language}::text IS NULL OR LOWER(language) = LOWER(${language})) + AND (${tagsJson}::jsonb IS NULL OR tags @> ${tagsJson}::jsonb) + AND ( + ${keyword}::text IS NULL + OR ( + setweight(to_tsvector('simple', COALESCE(title, '')), 'A') || + setweight(to_tsvector('simple', COALESCE(description, '')), 'B') || + setweight(to_tsvector('simple', COALESCE(code, '')), 'C') || + setweight(to_tsvector('simple', COALESCE(language, '')), 'B') || + setweight(jsonb_to_tsvector('simple', COALESCE(tags, '[]'::jsonb), '["string"]'), 'B') + ) @@ websearch_to_tsquery('simple', ${keyword}) + ) + `; + + const total = Number(countResult[0]?.total ?? 0); + + const result = await this.sql` + SELECT * + FROM snippets + WHERE (${title}::text IS NULL OR title ILIKE ${titlePattern}) + AND (${language}::text IS NULL OR LOWER(language) = LOWER(${language})) + AND (${tagsJson}::jsonb IS NULL OR tags @> ${tagsJson}::jsonb) + AND ( + ${keyword}::text IS NULL + OR ( + setweight(to_tsvector('simple', COALESCE(title, '')), 'A') || + setweight(to_tsvector('simple', COALESCE(description, '')), 'B') || + setweight(to_tsvector('simple', COALESCE(code, '')), 'C') || + setweight(to_tsvector('simple', COALESCE(language, '')), 'B') || + setweight(jsonb_to_tsvector('simple', COALESCE(tags, '[]'::jsonb), '["string"]'), 'B') + ) @@ websearch_to_tsquery('simple', ${keyword}) + ) + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} + `; + + const data = result as any[]; + + return { + data, + total, + limit, + offset, + hasMore: offset + data.length < total, + }; + } + async findById(id: string) { const result = await this.sql` SELECT * FROM snippets WHERE id = ${id} diff --git a/app/api/snippets/snippet.service.ts b/app/api/snippets/snippet.service.ts index 9151683..1a90a53 100644 --- a/app/api/snippets/snippet.service.ts +++ b/app/api/snippets/snippet.service.ts @@ -1,4 +1,9 @@ -import { SnippetRepository, PaginationOptions, PaginatedResult } from "./snippet.repository"; +import { + SnippetRepository, + PaginationOptions, + PaginatedResult, + SearchSnippetsOptions, +} from "./snippet.repository"; import { createSnippetSchema, updateSnippetSchema } from "./snippet.validator"; export class SnippetService { @@ -13,6 +18,15 @@ export class SnippetService { } } + async searchSnippets(options: SearchSnippetsOptions): Promise> { + try { + return await this.snippetRepository.search(options); + } catch (error) { + console.error("[Service] Error searching snippets:", error); + throw new Error("Failed to search snippets"); + } + } + async getSnippetById(id: string) { try { const snippet = await this.snippetRepository.findById(id); diff --git a/lib/snippet.service.test.ts b/lib/snippet.service.test.ts index 4e762a6..c3017d7 100644 --- a/lib/snippet.service.test.ts +++ b/lib/snippet.service.test.ts @@ -4,6 +4,7 @@ import { SnippetRepository } from "../app/api/snippets/snippet.repository"; // Mock the repository const mockRepository = { findAll: jest.fn(), + search: jest.fn(), findById: jest.fn(), create: jest.fn(), update: jest.fn(), @@ -48,6 +49,42 @@ describe("SnippetService", () => { }); }); + describe("searchSnippets", () => { + it("should return filtered snippets", async () => { + const filters = { + title: "React", + language: "typescript", + tags: ["frontend"], + keyword: "hooks", + limit: 10, + offset: 0, + }; + const mockResult = { + data: [{ id: "1", title: "React Hooks" }], + total: 1, + limit: 10, + offset: 0, + hasMore: false, + }; + + (mockRepository.search as jest.Mock).mockResolvedValue(mockResult); + + const result = await service.searchSnippets(filters); + expect(result).toEqual(mockResult); + expect(mockRepository.search).toHaveBeenCalledWith(filters); + }); + + it("should throw error when search fails", async () => { + (mockRepository.search as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + await expect( + service.searchSnippets({ limit: 10, offset: 0, keyword: "react" }), + ).rejects.toThrow("Failed to search snippets"); + }); + }); + describe("getSnippetById", () => { it("should return snippet by id", async () => { const mockSnippet = { id: "1", title: "Test Snippet" }; diff --git a/scripts/add-snippet-search-indexes.sql b/scripts/add-snippet-search-indexes.sql new file mode 100644 index 0000000..9c857e1 --- /dev/null +++ b/scripts/add-snippet-search-indexes.sql @@ -0,0 +1,14 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_snippets_lower_language ON snippets(LOWER(language)); +CREATE INDEX IF NOT EXISTS idx_snippets_title_trgm ON snippets USING GIN (title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_snippets_tags_gin ON snippets USING GIN (tags jsonb_path_ops); +CREATE INDEX IF NOT EXISTS idx_snippets_search_vector ON snippets USING GIN ( + ( + setweight(to_tsvector('simple', COALESCE(title, '')), 'A') || + setweight(to_tsvector('simple', COALESCE(description, '')), 'B') || + setweight(to_tsvector('simple', COALESCE(code, '')), 'C') || + setweight(to_tsvector('simple', COALESCE(language, '')), 'B') || + setweight(jsonb_to_tsvector('simple', COALESCE(tags, '[]'::jsonb), '["string"]'), 'B') + ) +); diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 52c90da..976205e 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -1,6 +1,8 @@ -- Drop tables if they exist (for fresh setup) DROP TABLE IF EXISTS snippets CASCADE; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + -- Create snippets table CREATE TABLE snippets ( id UUID PRIMARY KEY, @@ -16,4 +18,16 @@ CREATE TABLE snippets ( -- Create indexes for better query performance CREATE INDEX IF NOT EXISTS idx_snippets_language ON snippets(language); +CREATE INDEX IF NOT EXISTS idx_snippets_lower_language ON snippets(LOWER(language)); CREATE INDEX IF NOT EXISTS idx_snippets_created_at ON snippets(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_snippets_title_trgm ON snippets USING GIN (title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_snippets_tags_gin ON snippets USING GIN (tags jsonb_path_ops); +CREATE INDEX IF NOT EXISTS idx_snippets_search_vector ON snippets USING GIN ( + ( + setweight(to_tsvector('simple', COALESCE(title, '')), 'A') || + setweight(to_tsvector('simple', COALESCE(description, '')), 'B') || + setweight(to_tsvector('simple', COALESCE(code, '')), 'C') || + setweight(to_tsvector('simple', COALESCE(language, '')), 'B') || + setweight(jsonb_to_tsvector('simple', COALESCE(tags, '[]'::jsonb), '["string"]'), 'B') + ) +);