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 /packages/api/routes | |
| 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 'packages/api/routes')
| -rw-r--r-- | packages/api/routes/assets.ts | 97 | ||||
| -rw-r--r-- | packages/api/routes/bookmarks.ts | 252 | ||||
| -rw-r--r-- | packages/api/routes/highlights.ts | 54 | ||||
| -rw-r--r-- | packages/api/routes/lists.ts | 70 | ||||
| -rw-r--r-- | packages/api/routes/tags.ts | 60 | ||||
| -rw-r--r-- | packages/api/routes/users.ts | 20 |
6 files changed, 553 insertions, 0 deletions
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts new file mode 100644 index 00000000..de4e384d --- /dev/null +++ b/packages/api/routes/assets.ts @@ -0,0 +1,97 @@ +import { zValidator } from "@hono/zod-validator"; +import { and, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { stream } from "hono/streaming"; +import { z } from "zod"; + +import { assets } from "@karakeep/db/schema"; +import { + createAssetReadStream, + getAssetSize, + readAssetMetadata, +} from "@karakeep/shared/assetdb"; + +import { authMiddleware } from "../middlewares/auth"; +import { toWebReadableStream, uploadAsset } from "../utils/upload"; + +const app = new Hono() + .use(authMiddleware) + .post( + "/", + zValidator( + "form", + z + .object({ file: z.instanceof(File) }) + .or(z.object({ image: z.instanceof(File) })), + ), + async (c) => { + const body = c.req.valid("form"); + const up = await uploadAsset(c.var.ctx.user, c.var.ctx.db, body); + if ("error" in up) { + return c.json({ error: up.error }, up.status); + } + return c.json({ + assetId: up.assetId, + contentType: up.contentType, + size: up.size, + fileName: up.fileName, + }); + }, + ) + .get("/:assetId", async (c) => { + const assetId = c.req.param("assetId"); + const assetDb = await c.var.ctx.db.query.assets.findFirst({ + where: and(eq(assets.id, assetId), eq(assets.userId, c.var.ctx.user.id)), + }); + + if (!assetDb) { + return c.json({ error: "Asset not found" }, { status: 404 }); + } + + const [metadata, size] = await Promise.all([ + readAssetMetadata({ + userId: c.var.ctx.user.id, + assetId, + }), + + getAssetSize({ + userId: c.var.ctx.user.id, + assetId, + }), + ]); + + const range = c.req.header("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 fStream = createAssetReadStream({ + userId: c.var.ctx.user.id, + assetId, + start, + end, + }); + c.status(206); // Partial Content + c.header("Content-Range", `bytes ${start}-${end}/${size}`); + c.header("Accept-Ranges", "bytes"); + c.header("Content-Length", (end - start + 1).toString()); + c.header("Content-type", metadata.contentType); + return stream(c, async (stream) => { + await stream.pipe(toWebReadableStream(fStream)); + }); + } else { + const fStream = createAssetReadStream({ + userId: c.var.ctx.user.id, + assetId, + }); + c.status(200); + c.header("Content-Length", size.toString()); + c.header("Content-type", metadata.contentType); + return stream(c, async (stream) => { + await stream.pipe(toWebReadableStream(fStream)); + }); + } + }); + +export default app; diff --git a/packages/api/routes/bookmarks.ts b/packages/api/routes/bookmarks.ts new file mode 100644 index 00000000..fbc46d2f --- /dev/null +++ b/packages/api/routes/bookmarks.ts @@ -0,0 +1,252 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +import { + BookmarkTypes, + zAssetSchema, + zManipulatedTagSchema, + zNewBookmarkRequestSchema, + zUpdateBookmarksRequestSchema, +} from "@karakeep/shared/types/bookmarks"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { + zGetBookmarkQueryParamsSchema, + zGetBookmarkSearchParamsSchema, + zIncludeContentSearchParamsSchema, + zStringBool, +} from "../utils/types"; +import { uploadAsset } from "../utils/upload"; + +const app = new Hono() + .use(authMiddleware) + + // GET /bookmarks + .get( + "/", + zValidator( + "query", + z + .object({ + favourited: zStringBool.optional(), + archived: zStringBool.optional(), + }) + .and(zGetBookmarkQueryParamsSchema) + .and(zPagination), + ), + async (c) => { + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.getBookmarks(searchParams); + return c.json(adaptPagination(bookmarks), 200); + }, + ) + + // POST /bookmarks + .post("/", zValidator("json", zNewBookmarkRequestSchema), async (c) => { + const body = c.req.valid("json"); + const bookmark = await c.var.api.bookmarks.createBookmark(body); + return c.json(bookmark, 201); + }) + + // GET /bookmarks/search + .get( + "/search", + zValidator( + "query", + z + .object({ + q: z.string(), + limit: z.coerce.number().optional(), + cursor: z + .string() + .optional() + .transform((val) => + val ? { ver: 1 as const, offset: parseInt(val) } : undefined, + ), + }) + .and(zGetBookmarkSearchParamsSchema), + ), + async (c) => { + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.searchBookmarks({ + text: searchParams.q, + cursor: searchParams.cursor, + limit: searchParams.limit, + includeContent: searchParams.includeContent, + }); + return c.json( + { + bookmarks: bookmarks.bookmarks, + nextCursor: bookmarks.nextCursor + ? `${bookmarks.nextCursor.offset}` + : null, + }, + 200, + ); + }, + ) + .post( + "/singlefile", + zValidator( + "form", + z.object({ + url: z.string(), + file: z.instanceof(File), + }), + ), + async (c) => { + const form = c.req.valid("form"); + const up = await uploadAsset(c.var.ctx.user, c.var.ctx.db, form); + if ("error" in up) { + return c.json({ error: up.error }, up.status); + } + const bookmark = await c.var.api.bookmarks.createBookmark({ + type: BookmarkTypes.LINK, + url: form.url, + precrawledArchiveId: up.assetId, + }); + return c.json(bookmark, 201); + }, + ) + + // GET /bookmarks/[bookmarkId] + .get( + "/:bookmarkId", + zValidator("query", zIncludeContentSearchParamsSchema), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const searchParams = c.req.valid("query"); + const bookmark = await c.var.api.bookmarks.getBookmark({ + bookmarkId, + includeContent: searchParams.includeContent, + }); + return c.json(bookmark, 200); + }, + ) + + // PATCH /bookmarks/[bookmarkId] + .patch( + "/:bookmarkId", + zValidator( + "json", + zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }), + ), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const bookmark = await c.var.api.bookmarks.updateBookmark({ + bookmarkId, + ...body, + }); + return c.json(bookmark, 200); + }, + ) + + // DELETE /bookmarks/[bookmarkId] + .delete("/:bookmarkId", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + await c.var.api.bookmarks.deleteBookmark({ bookmarkId }); + return c.body(null, 204); + }) + + // GET /bookmarks/[bookmarkId]/lists + .get("/:bookmarkId/lists", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const resp = await c.var.api.lists.getListsOfBookmark({ bookmarkId }); + return c.json(resp, 200); + }) + + // GET /bookmarks/[bookmarkId]/assets + .get("/:bookmarkId/assets", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const resp = await c.var.api.bookmarks.getBookmark({ bookmarkId }); + return c.json({ assets: resp.assets }, 200); + }) + + // POST /bookmarks/[bookmarkId]/assets + .post("/:bookmarkId/assets", zValidator("json", zAssetSchema), async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const asset = await c.var.api.assets.attachAsset({ + bookmarkId, + asset: body, + }); + return c.json(asset, 201); + }) + + // PUT /bookmarks/[bookmarkId]/assets/[assetId] + .put( + "/:bookmarkId/assets/:assetId", + zValidator("json", z.object({ assetId: z.string() })), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const assetId = c.req.param("assetId"); + const body = c.req.valid("json"); + await c.var.api.assets.replaceAsset({ + bookmarkId, + oldAssetId: assetId, + newAssetId: body.assetId, + }); + return c.body(null, 204); + }, + ) + + // DELETE /bookmarks/[bookmarkId]/assets/[assetId] + .delete("/:bookmarkId/assets/:assetId", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const assetId = c.req.param("assetId"); + await c.var.api.assets.detachAsset({ bookmarkId, assetId }); + return c.body(null, 204); + }) + + // POST /bookmarks/[bookmarkId]/tags + .post( + "/:bookmarkId/tags", + zValidator("json", z.object({ tags: z.array(zManipulatedTagSchema) })), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const resp = await c.var.api.bookmarks.updateTags({ + bookmarkId, + attach: body.tags, + detach: [], + }); + return c.json({ attached: resp.attached }, 200); + }, + ) + + // DELETE /bookmarks/[bookmarkId]/tags + .delete( + "/:bookmarkId/tags", + zValidator("json", z.object({ tags: z.array(zManipulatedTagSchema) })), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const resp = await c.var.api.bookmarks.updateTags({ + bookmarkId, + detach: body.tags, + attach: [], + }); + return c.json({ detached: resp.detached }, 200); + }, + ) + + // POST /bookmarks/[bookmarkId]/summarize + .post("/:bookmarkId/summarize", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const bookmark = await c.var.api.bookmarks.summarizeBookmark({ + bookmarkId, + }); + return c.json(bookmark, 200); + }) + + // GET /bookmarks/[bookmarkId]/highlights + .get("/:bookmarkId/highlights", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const resp = await c.var.api.highlights.getForBookmark({ bookmarkId }); + return c.json(resp, 200); + }); + +export default app; diff --git a/packages/api/routes/highlights.ts b/packages/api/routes/highlights.ts new file mode 100644 index 00000000..d381f7e2 --- /dev/null +++ b/packages/api/routes/highlights.ts @@ -0,0 +1,54 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { + zNewHighlightSchema, + zUpdateHighlightSchema, +} from "@karakeep/shared/types/highlights"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; + +const app = new Hono() + .use(authMiddleware) + .get("/", zValidator("query", zPagination), async (c) => { + const searchParams = c.req.valid("query"); + const resp = await c.var.api.highlights.getAll({ + ...searchParams, + }); + return c.json(adaptPagination(resp)); + }) + .post("/", zValidator("json", zNewHighlightSchema), async (c) => { + const body = c.req.valid("json"); + const resp = await c.var.api.highlights.create(body); + return c.json(resp, 201); + }) + .get("/:highlightId", async (c) => { + const highlightId = c.req.param("highlightId"); + const highlight = await c.var.api.highlights.get({ + highlightId, + }); + return c.json(highlight, 200); + }) + .patch( + "/:highlightId", + zValidator("json", zUpdateHighlightSchema.omit({ highlightId: true })), + async (c) => { + const highlightId = c.req.param("highlightId"); + const body = c.req.valid("json"); + const highlight = await c.var.api.highlights.update({ + highlightId, + ...body, + }); + return c.json(highlight, 200); + }, + ) + .delete("/:highlightId", async (c) => { + const highlightId = c.req.param("highlightId"); + const highlight = await c.var.api.highlights.delete({ + highlightId, + }); + return c.json(highlight, 200); + }); + +export default app; diff --git a/packages/api/routes/lists.ts b/packages/api/routes/lists.ts new file mode 100644 index 00000000..33908629 --- /dev/null +++ b/packages/api/routes/lists.ts @@ -0,0 +1,70 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { + zEditBookmarkListSchema, + zNewBookmarkListSchema, +} from "@karakeep/shared/types/lists"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { zGetBookmarkQueryParamsSchema } from "../utils/types"; + +const app = new Hono() + .use(authMiddleware) + .get("/", async (c) => { + const lists = await c.var.api.lists.list(); + return c.json(lists, 200); + }) + .post("/", zValidator("json", zNewBookmarkListSchema), async (c) => { + const body = c.req.valid("json"); + const list = await c.var.api.lists.create(body); + return c.json(list, 201); + }) + .get("/:listId", async (c) => { + const listId = c.req.param("listId"); + const list = await c.var.api.lists.get({ listId }); + return c.json(list, 200); + }) + .patch( + "/:listId", + zValidator("json", zEditBookmarkListSchema.omit({ listId: true })), + async (c) => { + const listId = c.req.param("listId"); + const body = c.req.valid("json"); + const list = await c.var.api.lists.edit({ ...body, listId }); + return c.json(list, 200); + }, + ) + .delete("/:listId", async (c) => { + const listId = c.req.param("listId"); + await c.var.api.lists.delete({ listId }); + return c.body(null, 204); + }) + .get( + "/:listId/bookmarks", + zValidator("query", zPagination.and(zGetBookmarkQueryParamsSchema)), + async (c) => { + const listId = c.req.param("listId"); + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.getBookmarks({ + listId, + ...searchParams, + }); + return c.json(adaptPagination(bookmarks), 200); + }, + ) + .put("/:listId/bookmarks/:bookmarkId", async (c) => { + const listId = c.req.param("listId"); + const bookmarkId = c.req.param("bookmarkId"); + await c.var.api.lists.addToList({ listId, bookmarkId }); + return c.body(null, 204); + }) + .delete("/:listId/bookmarks/:bookmarkId", async (c) => { + const listId = c.req.param("listId"); + const bookmarkId = c.req.param("bookmarkId"); + await c.var.api.lists.removeFromList({ listId, bookmarkId }); + return c.body(null, 204); + }); + +export default app; diff --git a/packages/api/routes/tags.ts b/packages/api/routes/tags.ts new file mode 100644 index 00000000..6d4cf39d --- /dev/null +++ b/packages/api/routes/tags.ts @@ -0,0 +1,60 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { zUpdateTagRequestSchema } from "@karakeep/shared/types/tags"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { zGetBookmarkQueryParamsSchema } from "../utils/types"; + +const app = new Hono() + .use(authMiddleware) + + // GET /tags + .get("/", async (c) => { + const tags = await c.var.api.tags.list(); + return c.json(tags, 200); + }) + + // GET /tags/[tagId] + .get("/:tagId", async (c) => { + const tagId = c.req.param("tagId"); + const tag = await c.var.api.tags.get({ tagId }); + return c.json(tag, 200); + }) + + // PATCH /tags/[tagId] + .patch( + "/:tagId", + zValidator("json", zUpdateTagRequestSchema.omit({ tagId: true })), + async (c) => { + const tagId = c.req.param("tagId"); + const body = c.req.valid("json"); + const tag = await c.var.api.tags.update({ tagId, ...body }); + return c.json(tag, 200); + }, + ) + + // DELETE /tags/[tagId] + .delete("/:tagId", async (c) => { + const tagId = c.req.param("tagId"); + await c.var.api.tags.delete({ tagId }); + return c.body(null, 204); + }) + + // GET /tags/[tagId]/bookmarks + .get( + "/:tagId/bookmarks", + zValidator("query", zPagination.and(zGetBookmarkQueryParamsSchema)), + async (c) => { + const tagId = c.req.param("tagId"); + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.getBookmarks({ + tagId, + ...searchParams, + }); + return c.json(adaptPagination(bookmarks), 200); + }, + ); + +export default app; diff --git a/packages/api/routes/users.ts b/packages/api/routes/users.ts new file mode 100644 index 00000000..81177fe3 --- /dev/null +++ b/packages/api/routes/users.ts @@ -0,0 +1,20 @@ +import { Hono } from "hono"; + +import { authMiddleware } from "../middlewares/auth"; + +const app = new Hono() + .use(authMiddleware) + + // GET /users/me + .get("/me", async (c) => { + const user = await c.var.api.users.whoami(); + return c.json(user, 200); + }) + + // GET /users/me/stats + .get("/me/stats", async (c) => { + const stats = await c.var.api.users.stats(); + return c.json(stats, 200); + }); + +export default app; |
