-
-
Notifications
You must be signed in to change notification settings - Fork 358
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
featue: Add infra for REST APIs and implement GET /bookmarks
- Loading branch information
1 parent
a822ff2
commit fb297ea
Showing
6 changed files
with
215 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) }; | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()}`, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters