diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-05-18 16:58:08 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-18 16:58:08 +0100 |
| commit | 3505cb7d6416d101a4fcb1be27fc22e0171bacd2 (patch) | |
| tree | ef9f55504b8a5b20add8c0ebe916972ab4ab0178 /apps/web | |
| parent | 74e74fa6425f072107de3a9bc9dd8f91c5ac9a7d (diff) | |
| download | karakeep-3505cb7d6416d101a4fcb1be27fc22e0171bacd2.tar.zst | |
refactor: Migrate from NextJs's API routes to Hono based routes for the API (#1432)
* Setup Hono and migrate the highlights API there
* Implement the tags and lists endpoint
* Implement the bookmarks and users endpoints
* Add the trpc error code adapter
* Remove the old nextjs handlers
* fix api key not found handling
* Fix trpc error handling
* Fix 204 handling
* Fix search ordering
* Implement the singlefile endpoint
* Implement the asset serving endpoints
* Implement webauth
* Add hono as a catch all route under api
* fix tests
Diffstat (limited to 'apps/web')
28 files changed, 29 insertions, 1142 deletions
diff --git a/apps/web/app/api/[[...route]]/route.ts b/apps/web/app/api/[[...route]]/route.ts new file mode 100644 index 00000000..8930671d --- /dev/null +++ b/apps/web/app/api/[[...route]]/route.ts @@ -0,0 +1,28 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { Hono } from "hono"; +import { createMiddleware } from "hono/factory"; +import { handle } from "hono/vercel"; + +import allApp from "@karakeep/api"; +import { Context } from "@karakeep/trpc"; + +export const runtime = "nodejs"; + +export const nextAuth = createMiddleware<{ + Variables: { + ctx: Context; + }; +}>(async (c, next) => { + const ctx = await createContextFromRequest(c.req.raw); + c.set("ctx", ctx); + await next(); +}); + +const app = new Hono().basePath("/api").use(nextAuth).route("/", allApp); + +export const GET = handle(app); +export const POST = handle(app); +export const PATCH = handle(app); +export const DELETE = handle(app); +export const OPTIONS = handle(app); +export const PUT = handle(app); diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts deleted file mode 100644 index 8abb9080..00000000 --- a/apps/web/app/api/assets/[assetId]/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { createContextFromRequest } from "@/server/api/client"; -import { and, eq } from "drizzle-orm"; - -import { assets } from "@karakeep/db/schema"; -import { - createAssetReadStream, - getAssetSize, - readAssetMetadata, -} from "@karakeep/shared/assetdb"; - -export const dynamic = "force-dynamic"; -export async function GET( - request: Request, - { params }: { params: { assetId: string } }, -) { - const ctx = await createContextFromRequest(request); - if (!ctx.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const assetDb = await ctx.db.query.assets.findFirst({ - where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), - }); - - if (!assetDb) { - return Response.json({ error: "Asset not found" }, { status: 404 }); - } - - const [metadata, size] = await Promise.all([ - readAssetMetadata({ - userId: ctx.user.id, - assetId: params.assetId, - }), - - getAssetSize({ - userId: ctx.user.id, - assetId: params.assetId, - }), - ]); - - const range = request.headers.get("Range"); - if (range) { - const parts = range.replace(/bytes=/, "").split("-"); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : size - 1; - - const stream = createAssetReadStream({ - userId: ctx.user.id, - assetId: params.assetId, - start, - end, - }); - - return new Response( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - stream as any, - { - status: 206, // Partial Content - headers: { - "Content-Range": `bytes ${start}-${end}/${size}`, - "Accept-Ranges": "bytes", - "Content-Length": (end - start + 1).toString(), - "Content-type": metadata.contentType, - }, - }, - ); - } else { - const stream = createAssetReadStream({ - userId: ctx.user.id, - assetId: params.assetId, - }); - - return new Response( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - stream as any, - { - status: 200, - headers: { - "Content-Length": size.toString(), - "Content-type": metadata.contentType, - }, - }, - ); - } -} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts deleted file mode 100644 index e2e1e63e..00000000 --- a/apps/web/app/api/assets/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { Readable } from "stream"; -import { pipeline } from "stream/promises"; -import { createContextFromRequest } from "@/server/api/client"; -import { TRPCError } from "@trpc/server"; - -import type { ZUploadResponse } from "@karakeep/shared/types/uploads"; -import { assets, AssetTypes } from "@karakeep/db/schema"; -import { - newAssetId, - saveAssetFromFile, - SUPPORTED_UPLOAD_ASSET_TYPES, -} from "@karakeep/shared/assetdb"; -import serverConfig from "@karakeep/shared/config"; -import { AuthedContext } from "@karakeep/trpc"; - -const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024; - -export const dynamic = "force-dynamic"; - -// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18) -function webStreamToNode(webStream: ReadableStream<Uint8Array>): Readable { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - return Readable.fromWeb(webStream as any); // Type assertion might be needed -} - -export async function uploadFromPostData( - user: AuthedContext["user"], - db: AuthedContext["db"], - formData: FormData, -): Promise< - | { error: string; status: number } - | { - assetId: string; - contentType: string; - fileName: string; - size: number; - } -> { - const data = formData.get("file") ?? formData.get("image"); - - if (!(data instanceof File)) { - return { error: "Bad request", status: 400 }; - } - - const contentType = data.type; - const fileName = data.name; - if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) { - return { error: "Unsupported asset type", status: 400 }; - } - if (data.size > MAX_UPLOAD_SIZE_BYTES) { - return { error: "Asset is too big", status: 413 }; - } - - let tempFilePath: string | undefined; - - try { - tempFilePath = path.join(os.tmpdir(), `karakeep-upload-${Date.now()}`); - await pipeline( - webStreamToNode(data.stream()), - fs.createWriteStream(tempFilePath), - ); - const [assetDb] = await db - .insert(assets) - .values({ - id: newAssetId(), - // Initially, uploads are uploaded for unknown purpose - // And without an attached bookmark. - assetType: AssetTypes.UNKNOWN, - bookmarkId: null, - userId: user.id, - contentType, - size: data.size, - fileName, - }) - .returning(); - - await saveAssetFromFile({ - userId: user.id, - assetId: assetDb.id, - assetPath: tempFilePath, - metadata: { contentType, fileName }, - }); - - return { - assetId: assetDb.id, - contentType, - size: data.size, - fileName, - }; - } finally { - if (tempFilePath) { - await fs.promises.unlink(tempFilePath).catch(() => ({})); - } - } -} - -export async function POST(request: Request) { - const ctx = await createContextFromRequest(request); - if (ctx.user === null) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - if (serverConfig.demoMode) { - throw new TRPCError({ - message: "Mutations are not allowed in demo mode", - code: "FORBIDDEN", - }); - } - const formData = await request.formData(); - - const resp = await uploadFromPostData(ctx.user, ctx.db, formData); - if ("error" in resp) { - return Response.json({ error: resp.error }, { status: resp.status }); - } - - return Response.json({ - assetId: resp.assetId, - contentType: resp.contentType, - size: resp.size, - fileName: resp.fileName, - } satisfies ZUploadResponse); -} diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts deleted file mode 100644 index 88e203de..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; -import { z } from "zod"; - -export const dynamic = "force-dynamic"; - -export const PUT = ( - req: NextRequest, - params: { params: { bookmarkId: string; assetId: string } }, -) => - buildHandler({ - req, - bodySchema: z.object({ assetId: z.string() }), - handler: async ({ api, body }) => { - await api.assets.replaceAsset({ - bookmarkId: params.params.bookmarkId, - oldAssetId: params.params.assetId, - newAssetId: body!.assetId, - }); - return { status: 204 }; - }, - }); - -export const DELETE = ( - req: NextRequest, - params: { params: { bookmarkId: string; assetId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - await api.assets.detachAsset({ - bookmarkId: params.params.bookmarkId, - assetId: params.params.assetId, - }); - return { status: 204 }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts deleted file mode 100644 index 6c7c70d7..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -import { zAssetSchema } from "@karakeep/shared/types/bookmarks"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const resp = await api.bookmarks.getBookmark({ - bookmarkId: params.params.bookmarkId, - }); - return { status: 200, resp: { assets: resp.assets } }; - }, - }); - -export const POST = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - bodySchema: zAssetSchema, - handler: async ({ api, body }) => { - const asset = await api.assets.attachAsset({ - bookmarkId: params.params.bookmarkId, - asset: body!, - }); - return { status: 201, resp: asset }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts deleted file mode 100644 index 4e1f87a0..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const resp = await api.highlights.getForBookmark({ - bookmarkId: params.params.bookmarkId, - }); - return { status: 200, resp }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts deleted file mode 100644 index ad3052c9..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const resp = await api.lists.getListsOfBookmark({ - bookmarkId: params.params.bookmarkId, - }); - return { status: 200, resp }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts deleted file mode 100644 index 9ad18fd3..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -import { zUpdateBookmarksRequestSchema } from "@karakeep/shared/types/bookmarks"; - -import { zGetBookmarkQueryParamsSchema } from "../../utils/types"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - { params }: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - searchParamsSchema: zGetBookmarkQueryParamsSchema, - handler: async ({ api, searchParams }) => { - const bookmark = await api.bookmarks.getBookmark({ - bookmarkId: params.bookmarkId, - includeContent: searchParams.includeContent, - }); - return { status: 200, resp: bookmark }; - }, - }); - -export const PATCH = ( - req: NextRequest, - { params }: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - bodySchema: zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }), - handler: async ({ api, body }) => { - const bookmark = await api.bookmarks.updateBookmark({ - bookmarkId: params.bookmarkId, - ...body!, - }); - return { status: 200, resp: bookmark }; - }, - }); - -export const DELETE = ( - req: NextRequest, - { params }: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - await api.bookmarks.deleteBookmark({ - bookmarkId: params.bookmarkId, - }); - return { status: 204 }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts deleted file mode 100644 index ea41cad4..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -export const dynamic = "force-dynamic"; - -export const POST = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const bookmark = await api.bookmarks.summarizeBookmark({ - bookmarkId: params.params.bookmarkId, - }); - - return { status: 200, resp: bookmark }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts deleted file mode 100644 index 00c28afa..00000000 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; -import { z } from "zod"; - -import { zManipulatedTagSchema } from "@karakeep/shared/types/bookmarks"; - -export const dynamic = "force-dynamic"; - -export const POST = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - bodySchema: z.object({ - tags: z.array(zManipulatedTagSchema), - }), - handler: async ({ api, body }) => { - const resp = await api.bookmarks.updateTags({ - bookmarkId: params.params.bookmarkId, - attach: body!.tags, - detach: [], - }); - return { status: 200, resp: { attached: resp.attached } }; - }, - }); - -export const DELETE = ( - req: NextRequest, - params: { params: { bookmarkId: string } }, -) => - buildHandler({ - req, - bodySchema: z.object({ - tags: z.array(zManipulatedTagSchema), - }), - handler: async ({ api, body }) => { - const resp = await api.bookmarks.updateTags({ - bookmarkId: params.params.bookmarkId, - detach: body!.tags, - attach: [], - }); - return { status: 200, resp: { detached: resp.detached } }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/route.ts b/apps/web/app/api/v1/bookmarks/route.ts deleted file mode 100644 index 4df4f6ad..00000000 --- a/apps/web/app/api/v1/bookmarks/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextRequest } from "next/server"; -import { z } from "zod"; - -import { - zNewBookmarkRequestSchema, - zSortOrder, -} from "@karakeep/shared/types/bookmarks"; - -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(), - sortOrder: zSortOrder - .exclude([zSortOrder.Enum.relevance]) - .optional() - .default(zSortOrder.Enum.desc), - // TODO: Change the default to false in a couple of releases. - includeContent: zStringBool.optional().default("true"), - }) - .and(zPagination), - handler: async ({ api, searchParams }) => { - const bookmarks = await api.bookmarks.getBookmarks({ - ...searchParams, - }); - return { status: 200, resp: adaptPagination(bookmarks) }; - }, - }); - -export const POST = (req: NextRequest) => - buildHandler({ - req, - bodySchema: zNewBookmarkRequestSchema, - handler: async ({ api, body }) => { - const bookmark = await api.bookmarks.createBookmark(body!); - return { status: 201, resp: bookmark }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/search/route.ts b/apps/web/app/api/v1/bookmarks/search/route.ts deleted file mode 100644 index e85c7954..00000000 --- a/apps/web/app/api/v1/bookmarks/search/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest } from "next/server"; -import { z } from "zod"; - -import { buildHandler } from "../../utils/handler"; -import { zGetBookmarkSearchParamsSchema } from "../../utils/types"; - -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(), - }) - .and(zGetBookmarkSearchParamsSchema), - handler: async ({ api, searchParams }) => { - const bookmarks = await api.bookmarks.searchBookmarks({ - text: searchParams.q, - cursor: searchParams.cursor, - sortOrder: searchParams.sortOrder, - limit: searchParams.limit, - includeContent: searchParams.includeContent, - }); - return { - status: 200, - resp: { - bookmarks: bookmarks.bookmarks, - nextCursor: bookmarks.nextCursor - ? `${bookmarks.nextCursor.offset}` - : null, - }, - }; - }, - }); diff --git a/apps/web/app/api/v1/bookmarks/singlefile/route.ts b/apps/web/app/api/v1/bookmarks/singlefile/route.ts deleted file mode 100644 index 7c1d7201..00000000 --- a/apps/web/app/api/v1/bookmarks/singlefile/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createContextFromRequest } from "@/server/api/client"; -import { TRPCError } from "@trpc/server"; - -import serverConfig from "@karakeep/shared/config"; -import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import { createCallerFactory } from "@karakeep/trpc"; -import { appRouter } from "@karakeep/trpc/routers/_app"; - -import { uploadFromPostData } from "../../../assets/route"; - -export const dynamic = "force-dynamic"; - -export async function POST(req: Request) { - const ctx = await createContextFromRequest(req); - if (!ctx.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - if (serverConfig.demoMode) { - throw new TRPCError({ - message: "Mutations are not allowed in demo mode", - code: "FORBIDDEN", - }); - } - const formData = await req.formData(); - const up = await uploadFromPostData(ctx.user, ctx.db, formData); - - if ("error" in up) { - return Response.json({ error: up.error }, { status: up.status }); - } - - const url = formData.get("url"); - if (!url) { - throw new TRPCError({ - message: "URL is required", - code: "BAD_REQUEST", - }); - } - if (typeof url !== "string") { - throw new TRPCError({ - message: "URL must be a string", - code: "BAD_REQUEST", - }); - } - - const createCaller = createCallerFactory(appRouter); - const api = createCaller(ctx); - - const bookmark = await api.bookmarks.createBookmark({ - type: BookmarkTypes.LINK, - url, - precrawledArchiveId: up.assetId, - }); - return Response.json(bookmark, { status: 201 }); -} diff --git a/apps/web/app/api/v1/highlights/[highlightId]/route.ts b/apps/web/app/api/v1/highlights/[highlightId]/route.ts deleted file mode 100644 index 50420427..00000000 --- a/apps/web/app/api/v1/highlights/[highlightId]/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -import { zUpdateHighlightSchema } from "@karakeep/shared/types/highlights"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - { params }: { params: { highlightId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const highlight = await api.highlights.get({ - highlightId: params.highlightId, - }); - return { status: 200, resp: highlight }; - }, - }); - -export const PATCH = ( - req: NextRequest, - { params }: { params: { highlightId: string } }, -) => - buildHandler({ - req, - bodySchema: zUpdateHighlightSchema.omit({ highlightId: true }), - handler: async ({ api, body }) => { - const highlight = await api.highlights.update({ - highlightId: params.highlightId, - ...body!, - }); - return { status: 200, resp: highlight }; - }, - }); - -export const DELETE = ( - req: NextRequest, - { params }: { params: { highlightId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const highlight = await api.highlights.delete({ - highlightId: params.highlightId, - }); - return { status: 200, resp: highlight }; - }, - }); diff --git a/apps/web/app/api/v1/highlights/route.ts b/apps/web/app/api/v1/highlights/route.ts deleted file mode 100644 index e95d84f6..00000000 --- a/apps/web/app/api/v1/highlights/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -import { zNewHighlightSchema } from "@karakeep/shared/types/highlights"; - -import { adaptPagination, zPagination } from "../utils/pagination"; - -export const dynamic = "force-dynamic"; - -export const GET = (req: NextRequest) => - buildHandler({ - req, - searchParamsSchema: zPagination, - handler: async ({ api, searchParams }) => { - const resp = await api.highlights.getAll({ - ...searchParams, - }); - return { status: 200, resp: adaptPagination(resp) }; - }, - }); - -export const POST = (req: NextRequest) => - buildHandler({ - req, - bodySchema: zNewHighlightSchema, - handler: async ({ body, api }) => { - const resp = await api.highlights.create(body!); - return { status: 201, resp }; - }, - }); diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts deleted file mode 100644 index 6efe2055..00000000 --- a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -export const dynamic = "force-dynamic"; - -export const PUT = ( - req: NextRequest, - { params }: { params: { listId: string; bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - // TODO: PUT is supposed to be idempotent, but we currently fail if the bookmark is already in the list. - await api.lists.addToList({ - listId: params.listId, - bookmarkId: params.bookmarkId, - }); - return { status: 204 }; - }, - }); - -export const DELETE = ( - req: NextRequest, - { params }: { params: { listId: string; bookmarkId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - await api.lists.removeFromList({ - listId: params.listId, - bookmarkId: params.bookmarkId, - }); - return { status: 204 }; - }, - }); diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts deleted file mode 100644 index daf78449..00000000 --- a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; -import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination"; -import { zGetBookmarkQueryParamsSchema } from "@/app/api/v1/utils/types"; - -export const dynamic = "force-dynamic"; - -export const GET = (req: NextRequest, params: { params: { listId: string } }) => - buildHandler({ - req, - searchParamsSchema: zPagination.and(zGetBookmarkQueryParamsSchema), - handler: async ({ api, searchParams }) => { - const bookmarks = await api.bookmarks.getBookmarks({ - listId: params.params.listId, - ...searchParams, - }); - return { status: 200, resp: adaptPagination(bookmarks) }; - }, - }); diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts deleted file mode 100644 index 2cddbfdb..00000000 --- a/apps/web/app/api/v1/lists/[listId]/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -import { zEditBookmarkListSchema } from "@karakeep/shared/types/lists"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - { params }: { params: { listId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const list = await api.lists.get({ - listId: params.listId, - }); - return { - status: 200, - resp: list, - }; - }, - }); - -export const PATCH = ( - req: NextRequest, - { params }: { params: { listId: string } }, -) => - buildHandler({ - req, - bodySchema: zEditBookmarkListSchema.omit({ listId: true }), - handler: async ({ api, body }) => { - const list = await api.lists.edit({ - ...body!, - listId: params.listId, - }); - return { status: 200, resp: list }; - }, - }); - -export const DELETE = ( - req: NextRequest, - { params }: { params: { listId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - await api.lists.delete({ - listId: params.listId, - }); - return { - status: 204, - }; - }, - }); diff --git a/apps/web/app/api/v1/lists/route.ts b/apps/web/app/api/v1/lists/route.ts deleted file mode 100644 index 5def2506..00000000 --- a/apps/web/app/api/v1/lists/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextRequest } from "next/server"; - -import { zNewBookmarkListSchema } from "@karakeep/shared/types/lists"; - -import { buildHandler } from "../utils/handler"; - -export const dynamic = "force-dynamic"; - -export const GET = (req: NextRequest) => - buildHandler({ - req, - handler: async ({ api }) => { - const lists = await api.lists.list(); - return { status: 200, resp: lists }; - }, - }); - -export const POST = (req: NextRequest) => - buildHandler({ - req, - bodySchema: zNewBookmarkListSchema, - handler: async ({ api, body }) => { - const list = await api.lists.create(body!); - return { status: 201, resp: list }; - }, - }); diff --git a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts b/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts deleted file mode 100644 index aaa5087b..00000000 --- a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; -import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination"; -import { zGetBookmarkQueryParamsSchema } from "@/app/api/v1/utils/types"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - { params }: { params: { tagId: string } }, -) => - buildHandler({ - req, - searchParamsSchema: zPagination.and(zGetBookmarkQueryParamsSchema), - handler: async ({ api, searchParams }) => { - const bookmarks = await api.bookmarks.getBookmarks({ - tagId: params.tagId, - sortOrder: searchParams.sortOrder, - limit: searchParams.limit, - cursor: searchParams.cursor, - }); - return { - status: 200, - resp: adaptPagination(bookmarks), - }; - }, - }); diff --git a/apps/web/app/api/v1/tags/[tagId]/route.ts b/apps/web/app/api/v1/tags/[tagId]/route.ts deleted file mode 100644 index 234d952d..00000000 --- a/apps/web/app/api/v1/tags/[tagId]/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NextRequest } from "next/server"; -import { buildHandler } from "@/app/api/v1/utils/handler"; - -import { zUpdateTagRequestSchema } from "@karakeep/shared/types/tags"; - -export const dynamic = "force-dynamic"; - -export const GET = ( - req: NextRequest, - { params }: { params: { tagId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - const tag = await api.tags.get({ - tagId: params.tagId, - }); - return { - status: 200, - resp: tag, - }; - }, - }); - -export const PATCH = ( - req: NextRequest, - { params }: { params: { tagId: string } }, -) => - buildHandler({ - req, - bodySchema: zUpdateTagRequestSchema.omit({ tagId: true }), - handler: async ({ api, body }) => { - const tag = await api.tags.update({ - tagId: params.tagId, - ...body!, - }); - return { status: 200, resp: tag }; - }, - }); - -export const DELETE = ( - req: NextRequest, - { params }: { params: { tagId: string } }, -) => - buildHandler({ - req, - handler: async ({ api }) => { - await api.tags.delete({ - tagId: params.tagId, - }); - return { - status: 204, - }; - }, - }); diff --git a/apps/web/app/api/v1/tags/route.ts b/apps/web/app/api/v1/tags/route.ts deleted file mode 100644 index 9625820c..00000000 --- a/apps/web/app/api/v1/tags/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { buildHandler } from "../utils/handler"; - -export const dynamic = "force-dynamic"; - -export const GET = (req: NextRequest) => - buildHandler({ - req, - handler: async ({ api }) => { - const tags = await api.tags.list(); - return { status: 200, resp: tags }; - }, - }); diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts deleted file mode 100644 index bf0a3ba2..00000000 --- a/apps/web/app/api/v1/users/me/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { buildHandler } from "../../utils/handler"; - -export const dynamic = "force-dynamic"; - -export const GET = (req: NextRequest) => - buildHandler({ - req, - handler: async ({ api }) => { - const user = await api.users.whoami(); - return { status: 200, resp: user }; - }, - }); diff --git a/apps/web/app/api/v1/users/me/stats/route.ts b/apps/web/app/api/v1/users/me/stats/route.ts deleted file mode 100644 index 359c3156..00000000 --- a/apps/web/app/api/v1/users/me/stats/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest } from "next/server"; - -import { buildHandler } from "../../../utils/handler"; - -export const dynamic = "force-dynamic"; - -export const GET = (req: NextRequest) => - buildHandler({ - req, - handler: async ({ api }) => { - const stats = await api.users.stats(); - return { status: 200, resp: stats }; - }, - }); diff --git a/apps/web/app/api/v1/utils/handler.ts b/apps/web/app/api/v1/utils/handler.ts deleted file mode 100644 index 9154506d..00000000 --- a/apps/web/app/api/v1/utils/handler.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 "@karakeep/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) { - if (req.headers.get("Content-Type") !== "application/json") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Content-Type must be application/json", - }); - } - - let bodyJson = undefined; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - bodyJson = await req.json(); - } catch (e) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid JSON: ${(e as Error).message}`, - }); - } - body = bodySchema.parse(bodyJson) as SchemaType<BodyT>; - } - - const { status, resp } = await handler({ - ctx, - api, - searchParams, - body, - } as InputT); - - return new Response(resp ? JSON.stringify(resp) : null, { - 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 { - const error = e as Error; - console.error( - `Unexpected error in: ${req.method} ${req.nextUrl.pathname}:\n${error.stack}`, - ); - 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 deleted file mode 100644 index 12a0b950..00000000 --- a/apps/web/app/api/v1/utils/pagination.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; - -import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks"; -import { zCursorV2 } from "@karakeep/shared/types/pagination"; - -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 deleted file mode 100644 index bf181ce4..00000000 --- a/apps/web/app/api/v1/utils/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod"; - -import { zSortOrder } from "@karakeep/shared/types/bookmarks"; - -export const zStringBool = z - .string() - .refine((val) => val === "true" || val === "false", "Must be true or false") - .transform((val) => val === "true"); - -export const zGetBookmarkQueryParamsSchema = z.object({ - sortOrder: zSortOrder - .exclude([zSortOrder.Enum.relevance]) - .optional() - .default(zSortOrder.Enum.desc), - // TODO: Change the default to false in a couple of releases. - includeContent: zStringBool.optional().default("true"), -}); - -export const zGetBookmarkSearchParamsSchema = z.object({ - sortOrder: zSortOrder.optional().default(zSortOrder.Enum.relevance), - // TODO: Change the default to false in a couple of releases. - includeContent: zStringBool.optional().default("true"), -}); diff --git a/apps/web/package.json b/apps/web/package.json index 34e4752a..062565d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@hookform/resolvers": "^3.3.4", + "@karakeep/api": "workspace:^0.1.0", "@karakeep/db": "workspace:^0.1.0", "@karakeep/shared": "workspace:^0.1.0", "@karakeep/shared-react": "workspace:^0.1.0", |
