Skip to content

Commit

Permalink
feat: Expose the search functionality in the REST API
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Jan 5, 2025
1 parent ce16eda commit 1f5d566
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 20 deletions.
39 changes: 39 additions & 0 deletions apps/web/app/api/v1/bookmarks/search/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
},
});
10 changes: 9 additions & 1 deletion packages/e2e_tests/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
services:
hoarder:
web:
build:
dockerfile: docker/Dockerfile
context: ../../
Expand All @@ -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
109 changes: 109 additions & 0 deletions packages/e2e_tests/tests/api/bookmarks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Check failure on line 323 in packages/e2e_tests/tests/api/bookmarks.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .length on an `any` value
});

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);

Check failure on line 358 in packages/e2e_tests/tests/api/bookmarks.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .length on an `any` value
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!,

Check failure on line 369 in packages/e2e_tests/tests/api/bookmarks.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe assignment of an `any` value
},
},
},
);

expect(secondResponse.status).toBe(200);
expect(secondPage!.bookmarks.length).toBe(2);

Check failure on line 376 in packages/e2e_tests/tests/api/bookmarks.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .length on an `any` value
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!,

Check failure on line 387 in packages/e2e_tests/tests/api/bookmarks.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe assignment of an `any` value
},
},
},
);

expect(finalResponse.status).toBe(200);
expect(finalPage!.bookmarks.length).toBe(1);

Check failure on line 394 in packages/e2e_tests/tests/api/bookmarks.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .length on an `any` value
expect(finalPage!.nextCursor).toBeNull();
});
});
52 changes: 52 additions & 0 deletions packages/open-api/hoarder-openapi-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions packages/open-api/lib/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions packages/sdk/src/hoarder-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/types/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
28 changes: 9 additions & 19 deletions packages/trpc/routers/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
zGetBookmarksResponseSchema,
zManipulatedTagSchema,
zNewBookmarkRequestSchema,
zSearchBookmarksCursor,
zSearchBookmarksRequestSchema,
zUpdateBookmarksRequestSchema,
} from "@hoarder/shared/types/bookmarks";

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
}
: {}),
});
Expand Down Expand Up @@ -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,
},
};
}),
Expand Down

0 comments on commit 1f5d566

Please sign in to comment.