Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 60 additions & 13 deletions app/api/snippets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -91,4 +138,4 @@ export async function POST(req: NextRequest) {
{ status: 500 },
);
}
}
}
68 changes: 68 additions & 0 deletions app/api/snippets/snippet.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
data: T[];
Expand Down Expand Up @@ -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}
Expand Down
16 changes: 15 additions & 1 deletion app/api/snippets/snippet.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,6 +18,15 @@ export class SnippetService {
}
}

async searchSnippets(options: SearchSnippetsOptions): Promise<PaginatedResult<any>> {
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);
Expand Down
37 changes: 37 additions & 0 deletions lib/snippet.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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" };
Expand Down
14 changes: 14 additions & 0 deletions scripts/add-snippet-search-indexes.sql
Original file line number Diff line number Diff line change
@@ -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')
)
);
14 changes: 14 additions & 0 deletions scripts/init-db.sql
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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')
)
);