diff options
| -rw-r--r-- | apps/web/app/api/v1/bookmarks/route.ts | 25 | ||||
| -rw-r--r-- | apps/web/app/api/v1/utils/handler.ts | 149 | ||||
| -rw-r--r-- | apps/web/app/api/v1/utils/pagination.ts | 32 | ||||
| -rw-r--r-- | apps/web/app/api/v1/utils/types.ts | 6 | ||||
| -rw-r--r-- | apps/web/server/api/client.ts | 2 | ||||
| -rw-r--r-- | packages/shared/types/bookmarks.ts | 2 |
6 files changed, 215 insertions, 1 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"); diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts index fb2d84bc..5cf2bbe3 100644 --- a/apps/web/server/api/client.ts +++ b/apps/web/server/api/client.ts @@ -56,3 +56,5 @@ export const createContext = async ( const createCaller = createCallerFactory(appRouter); export const api = createCaller(createContext); + +export const createTrcpClientFromCtx = createCaller; diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index 50797f29..a9708f73 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -119,7 +119,7 @@ export type ZNewBookmarkRequest = z.infer<typeof zNewBookmarkRequestSchema>; export const DEFAULT_NUM_BOOKMARKS_PER_PAGE = 20; export const MAX_NUM_BOOKMARKS_PER_PAGE = 100; -const zCursorV2 = z.object({ +export const zCursorV2 = z.object({ createdAt: z.date(), id: z.string(), }); |
