aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/app/api
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-10-20 13:54:05 +0000
committerMohamed Bassem <me@mbassem.com>2024-10-20 14:08:24 +0000
commitfb297eaadee9b741ddb6731a91eb4648020dcb3b (patch)
tree42b5c779dab4ef4d6afbd3a41cbcd6c4d5c86f07 /apps/web/app/api
parenta822ff26ce83db867d4589181d21da20592fbf7c (diff)
downloadkarakeep-fb297eaadee9b741ddb6731a91eb4648020dcb3b.tar.zst
featue: Add infra for REST APIs and implement GET /bookmarks
Diffstat (limited to 'apps/web/app/api')
-rw-r--r--apps/web/app/api/v1/bookmarks/route.ts25
-rw-r--r--apps/web/app/api/v1/utils/handler.ts149
-rw-r--r--apps/web/app/api/v1/utils/pagination.ts32
-rw-r--r--apps/web/app/api/v1/utils/types.ts6
4 files changed, 212 insertions, 0 deletions
diff --git a/apps/web/app/api/v1/bookmarks/route.ts b/apps/web/app/api/v1/bookmarks/route.ts
new file mode 100644
index 00000000..0d58901d
--- /dev/null
+++ b/apps/web/app/api/v1/bookmarks/route.ts
@@ -0,0 +1,25 @@
+import { NextRequest } from "next/server";
+import { z } from "zod";
+
+import { buildHandler } from "../utils/handler";
+import { adaptPagination, zPagination } from "../utils/pagination";
+import { zStringBool } from "../utils/types";
+
+export const dynamic = "force-dynamic";
+
+export const GET = (req: NextRequest) =>
+ buildHandler({
+ req,
+ searchParamsSchema: z
+ .object({
+ favourited: zStringBool.optional(),
+ archived: zStringBool.optional(),
+ })
+ .and(zPagination),
+ handler: async ({ api, searchParams }) => {
+ const bookmarks = await api.bookmarks.getBookmarks({
+ ...searchParams,
+ });
+ return { status: 200, resp: adaptPagination(bookmarks) };
+ },
+ });
diff --git a/apps/web/app/api/v1/utils/handler.ts b/apps/web/app/api/v1/utils/handler.ts
new file mode 100644
index 00000000..d66bb299
--- /dev/null
+++ b/apps/web/app/api/v1/utils/handler.ts
@@ -0,0 +1,149 @@
+import { NextRequest } from "next/server";
+import {
+ createContextFromRequest,
+ createTrcpClientFromCtx,
+} from "@/server/api/client";
+import { TRPCError } from "@trpc/server";
+import { z, ZodError } from "zod";
+
+import { Context } from "@hoarder/trpc";
+
+function trpcCodeToHttpCode(code: TRPCError["code"]) {
+ switch (code) {
+ case "BAD_REQUEST":
+ case "PARSE_ERROR":
+ return 400;
+ case "UNAUTHORIZED":
+ return 401;
+ case "FORBIDDEN":
+ return 403;
+ case "NOT_FOUND":
+ return 404;
+ case "METHOD_NOT_SUPPORTED":
+ return 405;
+ case "TIMEOUT":
+ return 408;
+ case "PAYLOAD_TOO_LARGE":
+ return 413;
+ case "INTERNAL_SERVER_ERROR":
+ return 500;
+ default:
+ return 500;
+ }
+}
+
+interface ErrorMessage {
+ path: (string | number)[];
+ message: string;
+}
+
+function formatZodError(error: ZodError): string {
+ if (!error.issues) {
+ return error.message || "An unknown error occurred";
+ }
+
+ const errors: ErrorMessage[] = error.issues.map((issue) => ({
+ path: issue.path,
+ message: issue.message,
+ }));
+
+ const formattedErrors = errors.map((err) => {
+ const path = err.path.join(".");
+ return path ? `${path}: ${err.message}` : err.message;
+ });
+
+ return `${formattedErrors.join(", ")}`;
+}
+
+export interface TrpcAPIRequest<SearchParamsT, BodyType> {
+ ctx: Context;
+ api: ReturnType<typeof createTrcpClientFromCtx>;
+ searchParams: SearchParamsT extends z.ZodTypeAny
+ ? z.infer<SearchParamsT>
+ : undefined;
+ body: BodyType extends z.ZodTypeAny
+ ? z.infer<BodyType> | undefined
+ : undefined;
+}
+
+type SchemaType<T> = T extends z.ZodTypeAny
+ ? z.infer<T> | undefined
+ : undefined;
+
+export async function buildHandler<
+ SearchParamsT extends z.ZodTypeAny | undefined,
+ BodyT extends z.ZodTypeAny | undefined,
+ InputT extends TrpcAPIRequest<SearchParamsT, BodyT>,
+>({
+ req,
+ handler,
+ searchParamsSchema,
+ bodySchema,
+}: {
+ req: NextRequest;
+ handler: (req: InputT) => Promise<{ status: number; resp: object }>;
+ searchParamsSchema?: SearchParamsT | undefined;
+ bodySchema?: BodyT | undefined;
+}) {
+ try {
+ const ctx = await createContextFromRequest(req);
+ const api = createTrcpClientFromCtx(ctx);
+
+ let searchParams: SchemaType<SearchParamsT> | undefined = undefined;
+ if (searchParamsSchema !== undefined) {
+ searchParams = searchParamsSchema.parse(
+ Object.fromEntries(req.nextUrl.searchParams.entries()),
+ ) as SchemaType<SearchParamsT>;
+ }
+
+ let body: SchemaType<BodyT> | undefined = undefined;
+ if (bodySchema) {
+ body = bodySchema.parse(await req.json()) as SchemaType<BodyT>;
+ }
+
+ const { status, resp } = await handler({
+ ctx,
+ api,
+ searchParams,
+ body,
+ } as InputT);
+
+ return new Response(JSON.stringify(resp), {
+ status,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ } catch (e) {
+ if (e instanceof ZodError) {
+ return new Response(
+ JSON.stringify({ code: "ParseError", message: formatZodError(e) }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ );
+ }
+ if (e instanceof TRPCError) {
+ let message = e.message;
+ if (e.cause instanceof ZodError) {
+ message = formatZodError(e.cause);
+ }
+ return new Response(JSON.stringify({ code: e.code, error: message }), {
+ status: trpcCodeToHttpCode(e.code),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ } else {
+ return new Response(JSON.stringify({ code: "UnknownError" }), {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ }
+ }
+}
diff --git a/apps/web/app/api/v1/utils/pagination.ts b/apps/web/app/api/v1/utils/pagination.ts
new file mode 100644
index 00000000..5ce9ac8f
--- /dev/null
+++ b/apps/web/app/api/v1/utils/pagination.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+import {
+ MAX_NUM_BOOKMARKS_PER_PAGE,
+ zCursorV2,
+} from "@hoarder/shared/types/bookmarks";
+
+export const zPagination = z.object({
+ limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
+ cursor: z
+ .string()
+ .refine((val) => val.includes("_"), "Must be a valid cursor")
+ .transform((val) => {
+ const [id, createdAt] = val.split("_");
+ return { id, createdAt };
+ })
+ .pipe(z.object({ id: z.string(), createdAt: z.coerce.date() }))
+ .optional(),
+});
+
+export function adaptPagination<
+ T extends { nextCursor: z.infer<typeof zCursorV2> | null },
+>(input: T) {
+ const { nextCursor, ...rest } = input;
+ if (!nextCursor) {
+ return input;
+ }
+ return {
+ ...rest,
+ nextCursor: `${nextCursor.id}_${nextCursor.createdAt.toISOString()}`,
+ };
+}
diff --git a/apps/web/app/api/v1/utils/types.ts b/apps/web/app/api/v1/utils/types.ts
new file mode 100644
index 00000000..c0e20dff
--- /dev/null
+++ b/apps/web/app/api/v1/utils/types.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const zStringBool = z
+ .string()
+ .refine((val) => val === "true" || val === "false", "Must be true or false")
+ .transform((val) => val === "true");