aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-01-05 12:01:42 +0000
committerMohamed Bassem <me@mbassem.com>2025-01-05 12:01:42 +0000
commit1f5d5668b7558ec4d0a77129041cba3ba6d72cb7 (patch)
tree547276fbc89d5337c2f32ff6bcb37abe05f5c5dc /packages
parentce16eda75f4d93646e485b7115398e81e7c88acc (diff)
downloadkarakeep-1f5d5668b7558ec4d0a77129041cba3ba6d72cb7.tar.zst
feat: Expose the search functionality in the REST API
Diffstat (limited to 'packages')
-rw-r--r--packages/e2e_tests/docker-compose.yml10
-rw-r--r--packages/e2e_tests/tests/api/bookmarks.test.ts109
-rw-r--r--packages/open-api/hoarder-openapi-spec.json52
-rw-r--r--packages/open-api/lib/bookmarks.ts26
-rw-r--r--packages/sdk/src/hoarder-api.d.ts43
-rw-r--r--packages/shared/types/bookmarks.ts12
-rw-r--r--packages/trpc/routers/bookmarks.ts28
7 files changed, 260 insertions, 20 deletions
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
@@ -74,6 +74,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",
description: "Create a new bookmark",
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,
},
};
}),