Skip to content

Commit

Permalink
featue: Add infra for REST APIs and implement GET /bookmarks
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Oct 20, 2024
1 parent a822ff2 commit fb297ea
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 1 deletion.
25 changes: 25 additions & 0 deletions apps/web/app/api/v1/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -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) };
},
});
149 changes: 149 additions & 0 deletions apps/web/app/api/v1/utils/handler.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
}
}
32 changes: 32 additions & 0 deletions apps/web/app/api/v1/utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -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()}`,
};
}
6 changes: 6 additions & 0 deletions apps/web/app/api/v1/utils/types.ts
Original file line number Diff line number Diff line change
@@ -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");
2 changes: 2 additions & 0 deletions apps/web/server/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ export const createContext = async (
const createCaller = createCallerFactory(appRouter);

export const api = createCaller(createContext);

export const createTrcpClientFromCtx = createCaller;
2 changes: 1 addition & 1 deletion packages/shared/types/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down

0 comments on commit fb297ea

Please sign in to comment.