From 1f5d5668b7558ec4d0a77129041cba3ba6d72cb7 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 5 Jan 2025 12:01:42 +0000 Subject: [PATCH] feat: Expose the search functionality in the REST API --- apps/web/app/api/v1/bookmarks/search/route.ts | 39 +++++++ packages/e2e_tests/docker-compose.yml | 10 +- .../e2e_tests/tests/api/bookmarks.test.ts | 109 ++++++++++++++++++ packages/open-api/hoarder-openapi-spec.json | 52 +++++++++ packages/open-api/lib/bookmarks.ts | 26 +++++ packages/sdk/src/hoarder-api.d.ts | 43 +++++++ packages/shared/types/bookmarks.ts | 12 ++ packages/trpc/routers/bookmarks.ts | 28 ++--- 8 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 apps/web/app/api/v1/bookmarks/search/route.ts diff --git a/apps/web/app/api/v1/bookmarks/search/route.ts b/apps/web/app/api/v1/bookmarks/search/route.ts new file mode 100644 index 00000000..f0c5417a --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/search/route.ts @@ -0,0 +1,39 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; + +import { buildHandler } from "../../utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + searchParamsSchema: z.object({ + q: z.string(), + limit: z.coerce.number().optional(), + cursor: z + .string() + // Search cursor V1 is just a number + .pipe(z.coerce.number()) + .transform((val) => { + return { ver: 1 as const, offset: val }; + }) + .optional(), + }), + handler: async ({ api, searchParams }) => { + const bookmarks = await api.bookmarks.searchBookmarks({ + text: searchParams.q, + cursor: searchParams.cursor, + limit: searchParams.limit, + }); + return { + status: 200, + resp: { + bookmarks: bookmarks.bookmarks, + nextCursor: bookmarks.nextCursor + ? `${bookmarks.nextCursor.offset}` + : null, + }, + }; + }, + }); diff --git a/packages/e2e_tests/docker-compose.yml b/packages/e2e_tests/docker-compose.yml index 2c75057c..c7d36c94 100644 --- a/packages/e2e_tests/docker-compose.yml +++ b/packages/e2e_tests/docker-compose.yml @@ -1,5 +1,5 @@ services: - hoarder: + web: build: dockerfile: docker/Dockerfile context: ../../ @@ -10,3 +10,11 @@ services: environment: DATA_DIR: /tmp NEXTAUTH_SECRET: secret + MEILI_MASTER_KEY: dummy + MEILI_ADDR: http://meilisearch:7700 + meilisearch: + image: getmeili/meilisearch:v1.11.1 + restart: unless-stopped + environment: + MEILI_NO_ANALYTICS: "true" + MEILI_MASTER_KEY: dummy diff --git a/packages/e2e_tests/tests/api/bookmarks.test.ts b/packages/e2e_tests/tests/api/bookmarks.test.ts index 727ca758..7c605aab 100644 --- a/packages/e2e_tests/tests/api/bookmarks.test.ts +++ b/packages/e2e_tests/tests/api/bookmarks.test.ts @@ -285,4 +285,113 @@ describe("Bookmarks API", () => { expect(removeTagsRes.status).toBe(200); }); + + it("should search bookmarks", async () => { + // Create test bookmarks + await client.POST("/bookmarks", { + body: { + type: "text", + title: "Search Test 1", + text: "This is a test bookmark for search", + }, + }); + await client.POST("/bookmarks", { + body: { + type: "text", + title: "Search Test 2", + text: "Another test bookmark for search", + }, + }); + + // Wait 3 seconds for the search index to be updated + // TODO: Replace with a check that all queues are empty + await new Promise((f) => setTimeout(f, 3000)); + + // Search for bookmarks + const { data: searchResults, response: searchResponse } = await client.GET( + "/bookmarks/search", + { + params: { + query: { + q: "test bookmark", + }, + }, + }, + ); + + expect(searchResponse.status).toBe(200); + expect(searchResults!.bookmarks.length).toBeGreaterThanOrEqual(2); + }); + + it("should paginate search results", async () => { + // Create multiple bookmarks + const bookmarkPromises = Array.from({ length: 5 }, (_, i) => + client.POST("/bookmarks", { + body: { + type: "text", + title: `Search Pagination ${i}`, + text: `This is test bookmark ${i} for pagination`, + }, + }), + ); + + await Promise.all(bookmarkPromises); + + // Wait 3 seconds for the search index to be updated + // TODO: Replace with a check that all queues are empty + await new Promise((f) => setTimeout(f, 3000)); + + // Get first page + const { data: firstPage, response: firstResponse } = await client.GET( + "/bookmarks/search", + { + params: { + query: { + q: "pagination", + limit: 2, + }, + }, + }, + ); + + expect(firstResponse.status).toBe(200); + expect(firstPage!.bookmarks.length).toBe(2); + expect(firstPage!.nextCursor).toBeDefined(); + + // Get second page + const { data: secondPage, response: secondResponse } = await client.GET( + "/bookmarks/search", + { + params: { + query: { + q: "pagination", + limit: 2, + cursor: firstPage!.nextCursor!, + }, + }, + }, + ); + + expect(secondResponse.status).toBe(200); + expect(secondPage!.bookmarks.length).toBe(2); + expect(secondPage!.nextCursor).toBeDefined(); + + // Get final page + const { data: finalPage, response: finalResponse } = await client.GET( + "/bookmarks/search", + { + params: { + query: { + q: "pagination", + limit: 2, + cursor: secondPage!.nextCursor!, + }, + }, + }, + ); + + expect(finalResponse.status).toBe(200); + expect(finalPage!.bookmarks.length).toBe(1); + expect(finalPage!.nextCursor).toBeNull(); + }); }); diff --git a/packages/open-api/hoarder-openapi-spec.json b/packages/open-api/hoarder-openapi-spec.json index 92088f48..7b2b9436 100644 --- a/packages/open-api/hoarder-openapi-spec.json +++ b/packages/open-api/hoarder-openapi-spec.json @@ -679,6 +679,58 @@ } } }, + "/bookmarks/search": { + "get": { + "description": "Search bookmarks", + "summary": "Search bookmarks", + "tags": [ + "Bookmarks" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": "number" + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "$ref": "#/components/schemas/Cursor" + }, + "required": false, + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Object with the search results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedBookmarks" + } + } + } + } + } + } + }, "/bookmarks/{bookmarkId}": { "get": { "description": "Get bookmark by its id", diff --git a/packages/open-api/lib/bookmarks.ts b/packages/open-api/lib/bookmarks.ts index 09288a4b..c7c05256 100644 --- a/packages/open-api/lib/bookmarks.ts +++ b/packages/open-api/lib/bookmarks.ts @@ -73,6 +73,32 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/bookmarks/search", + description: "Search bookmarks", + summary: "Search bookmarks", + tags: ["Bookmarks"], + security: [{ [BearerAuth.name]: [] }], + request: { + query: z + .object({ + q: z.string(), + }) + .merge(PaginationSchema), + }, + responses: { + 200: { + description: "Object with the search results.", + content: { + "application/json": { + schema: PaginatedBookmarksSchema, + }, + }, + }, + }, +}); + registry.registerPath({ method: "post", path: "/bookmarks", diff --git a/packages/sdk/src/hoarder-api.d.ts b/packages/sdk/src/hoarder-api.d.ts index 8aaeb503..f4d76a8a 100644 --- a/packages/sdk/src/hoarder-api.d.ts +++ b/packages/sdk/src/hoarder-api.d.ts @@ -105,6 +105,49 @@ export interface paths { patch?: never; trace?: never; }; + "/bookmarks/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search bookmarks + * @description Search bookmarks + */ + get: { + parameters: { + query: { + q: string; + limit?: number; + cursor?: components["schemas"]["Cursor"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Object with the search results. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PaginatedBookmarks"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bookmarks/{bookmarkId}": { parameters: { query?: never; diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index 8ee523a6..a1e39280 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -195,3 +195,15 @@ export const zManipulatedTagSchema = z message: "You must provide either a tagId or a tagName", path: ["tagId", "tagName"], }); + +export const zSearchBookmarksCursor = z.discriminatedUnion("ver", [ + z.object({ + ver: z.literal(1), + offset: z.number(), + }), +]); +export const zSearchBookmarksRequestSchema = z.object({ + text: z.string(), + limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(), + cursor: zSearchBookmarksCursor.nullish(), +}); diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index f3884053..15e4cb7c 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -45,6 +45,8 @@ import { zGetBookmarksResponseSchema, zManipulatedTagSchema, zNewBookmarkRequestSchema, + zSearchBookmarksCursor, + zSearchBookmarksRequestSchema, zUpdateBookmarksRequestSchema, } from "@hoarder/shared/types/bookmarks"; @@ -521,29 +523,17 @@ export const bookmarksAppRouter = router({ return await getBookmark(ctx, input.bookmarkId); }), searchBookmarks: authedProcedure - .input( - z.object({ - text: z.string(), - cursor: z - .object({ - offset: z.number(), - limit: z.number(), - }) - .nullish(), - }), - ) + .input(zSearchBookmarksRequestSchema) .output( z.object({ bookmarks: z.array(zBookmarkSchema), - nextCursor: z - .object({ - offset: z.number(), - limit: z.number(), - }) - .nullable(), + nextCursor: zSearchBookmarksCursor.nullable(), }), ) .query(async ({ input, ctx }) => { + if (!input.limit) { + input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; + } const client = await getSearchIdxClient(); if (!client) { throw new TRPCError({ @@ -571,10 +561,10 @@ export const bookmarksAppRouter = router({ showRankingScore: true, attributesToRetrieve: ["id"], sort: ["createdAt:desc"], + limit: input.limit, ...(input.cursor ? { offset: input.cursor.offset, - limit: input.cursor.limit, } : {}), }); @@ -614,8 +604,8 @@ export const bookmarksAppRouter = router({ resp.hits.length + resp.offset >= resp.estimatedTotalHits ? null : { + ver: 1 as const, offset: resp.hits.length + resp.offset, - limit: resp.limit, }, }; }),