aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts37
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts36
-rw-r--r--packages/e2e_tests/tests/api/assets.test.ts162
-rw-r--r--packages/open-api/hoarder-openapi-spec.json163
-rw-r--r--packages/open-api/lib/bookmarks.ts92
-rw-r--r--packages/sdk/src/hoarder-api.d.ts140
6 files changed, 630 insertions, 0 deletions
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
new file mode 100644
index 00000000..3fc50801
--- /dev/null
+++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
@@ -0,0 +1,37 @@
+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.bookmarks.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.bookmarks.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
new file mode 100644
index 00000000..e5284a39
--- /dev/null
+++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest } from "next/server";
+import { buildHandler } from "@/app/api/v1/utils/handler";
+
+import { zAssetSchema } from "@hoarder/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.bookmarks.attachAsset({
+ bookmarkId: params.params.bookmarkId,
+ asset: body!,
+ });
+ return { status: 201, resp: asset };
+ },
+ });
diff --git a/packages/e2e_tests/tests/api/assets.test.ts b/packages/e2e_tests/tests/api/assets.test.ts
index 0fab3d3f..0ed10dee 100644
--- a/packages/e2e_tests/tests/api/assets.test.ts
+++ b/packages/e2e_tests/tests/api/assets.test.ts
@@ -131,4 +131,166 @@ describe("Assets API", () => {
);
expect(assetResponse.status).toBe(404);
});
+
+ it("should manage assets on a bookmark", async () => {
+ // Create a new bookmark
+ const { data: createdBookmark, error: createError } = await client.POST(
+ "/bookmarks",
+ {
+ body: {
+ type: "text",
+ title: "Test Bookmark",
+ text: "This is a test bookmark",
+ },
+ },
+ );
+
+ if (createError) {
+ console.error("Error creating bookmark:", createError);
+ throw createError;
+ }
+ if (!createdBookmark) {
+ throw new Error("Bookmark creation failed");
+ }
+
+ const file = new File(["test content"], "test.pdf", {
+ type: "application/pdf",
+ });
+
+ // Upload the asset
+ const uploadResponse1 = await uploadTestAsset(apiKey, port, file);
+ const uploadResponse2 = await uploadTestAsset(apiKey, port, file);
+ const uploadResponse3 = await uploadTestAsset(apiKey, port, file);
+
+ // Attach first asset
+ const { data: firstAsset, response: attachFirstRes } = await client.POST(
+ "/bookmarks/{bookmarkId}/assets",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ body: {
+ id: uploadResponse1.assetId,
+ assetType: "bannerImage",
+ },
+ },
+ );
+
+ expect(attachFirstRes.status).toBe(201);
+ expect(firstAsset).toEqual({
+ id: uploadResponse1.assetId,
+ assetType: "bannerImage",
+ });
+
+ // Attach second asset
+ const { data: secondAsset, response: attachSecondRes } = await client.POST(
+ "/bookmarks/{bookmarkId}/assets",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ body: {
+ id: uploadResponse2.assetId,
+ assetType: "bannerImage",
+ },
+ },
+ );
+
+ expect(attachSecondRes.status).toBe(201);
+ expect(secondAsset).toEqual({
+ id: uploadResponse2.assetId,
+ assetType: "bannerImage",
+ });
+
+ // Get bookmark and verify assets
+ const { data: bookmarkWithAssets } = await client.GET(
+ "/bookmarks/{bookmarkId}",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ },
+ );
+
+ expect(bookmarkWithAssets?.assets).toEqual(
+ expect.arrayContaining([
+ { id: uploadResponse1.assetId, assetType: "bannerImage" },
+ { id: uploadResponse2.assetId, assetType: "bannerImage" },
+ ]),
+ );
+
+ // Replace first asset
+ const { response: replaceRes } = await client.PUT(
+ "/bookmarks/{bookmarkId}/assets/{assetId}",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ assetId: uploadResponse1.assetId,
+ },
+ },
+ body: {
+ assetId: uploadResponse3.assetId,
+ },
+ },
+ );
+
+ expect(replaceRes.status).toBe(204);
+
+ // Verify replacement
+ const { data: bookmarkAfterReplace } = await client.GET(
+ "/bookmarks/{bookmarkId}",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ },
+ );
+
+ expect(bookmarkAfterReplace?.assets).toEqual(
+ expect.arrayContaining([
+ { id: uploadResponse3.assetId, assetType: "bannerImage" },
+ { id: uploadResponse2.assetId, assetType: "bannerImage" },
+ ]),
+ );
+
+ // Detach second asset
+ const { response: detachRes } = await client.DELETE(
+ "/bookmarks/{bookmarkId}/assets/{assetId}",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ assetId: uploadResponse2.assetId,
+ },
+ },
+ },
+ );
+
+ expect(detachRes.status).toBe(204);
+
+ // Verify detachment
+ const { data: bookmarkAfterDetach } = await client.GET(
+ "/bookmarks/{bookmarkId}",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ },
+ );
+
+ expect(bookmarkAfterDetach?.assets).toEqual([
+ { id: uploadResponse3.assetId, assetType: "bannerImage" },
+ ]);
+ });
});
diff --git a/packages/open-api/hoarder-openapi-spec.json b/packages/open-api/hoarder-openapi-spec.json
index fecea0c2..92088f48 100644
--- a/packages/open-api/hoarder-openapi-spec.json
+++ b/packages/open-api/hoarder-openapi-spec.json
@@ -25,6 +25,10 @@
}
},
"schemas": {
+ "AssetId": {
+ "type": "string",
+ "example": "ieidlxygmwj87oxz5hxttoc8"
+ },
"BookmarkId": {
"type": "string",
"example": "ieidlxygmwj87oxz5hxttoc8"
@@ -435,6 +439,14 @@
}
},
"parameters": {
+ "AssetId": {
+ "schema": {
+ "$ref": "#/components/schemas/AssetId"
+ },
+ "required": true,
+ "name": "assetId",
+ "in": "path"
+ },
"BookmarkId": {
"schema": {
"$ref": "#/components/schemas/BookmarkId"
@@ -1008,6 +1020,157 @@
}
}
},
+ "/bookmarks/{bookmarkId}/assets": {
+ "post": {
+ "description": "Attach a new asset to a bookmark",
+ "summary": "Attach asset",
+ "tags": [
+ "Bookmarks"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/BookmarkId"
+ }
+ ],
+ "requestBody": {
+ "description": "The asset to attach",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "assetType": {
+ "type": "string",
+ "enum": [
+ "screenshot",
+ "bannerImage",
+ "fullPageArchive",
+ "video",
+ "bookmarkAsset",
+ "unknown"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "assetType"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The attached asset",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "assetType": {
+ "type": "string",
+ "enum": [
+ "screenshot",
+ "bannerImage",
+ "fullPageArchive",
+ "video",
+ "bookmarkAsset",
+ "unknown"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "assetType"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/bookmarks/{bookmarkId}/assets/{assetId}": {
+ "put": {
+ "description": "Replace an existing asset with a new one",
+ "summary": "Replace asset",
+ "tags": [
+ "Bookmarks"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/BookmarkId"
+ },
+ {
+ "$ref": "#/components/parameters/AssetId"
+ }
+ ],
+ "requestBody": {
+ "description": "The new asset to replace with",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "assetId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "assetId"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "204": {
+ "description": "No content - asset was replaced successfully"
+ }
+ }
+ },
+ "delete": {
+ "description": "Detach an asset from a bookmark",
+ "summary": "Detach asset",
+ "tags": [
+ "Bookmarks"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/BookmarkId"
+ },
+ {
+ "$ref": "#/components/parameters/AssetId"
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content - asset was detached successfully"
+ }
+ }
+ }
+ },
"/lists": {
"get": {
"description": "Get all lists",
diff --git a/packages/open-api/lib/bookmarks.ts b/packages/open-api/lib/bookmarks.ts
index 12a122fa..09288a4b 100644
--- a/packages/open-api/lib/bookmarks.ts
+++ b/packages/open-api/lib/bookmarks.ts
@@ -5,6 +5,7 @@ import {
import { z } from "zod";
import {
+ zAssetSchema,
zBareBookmarkSchema,
zManipulatedTagSchema,
zNewBookmarkRequestSchema,
@@ -23,6 +24,17 @@ import { TagIdSchema } from "./tags";
export const registry = new OpenAPIRegistry();
extendZodWithOpenApi(z);
+export const AssetIdSchema = registry.registerParameter(
+ "AssetId",
+ z.string().openapi({
+ param: {
+ name: "assetId",
+ in: "path",
+ },
+ example: "ieidlxygmwj87oxz5hxttoc8",
+ }),
+);
+
export const BookmarkIdSchema = registry.registerParameter(
"BookmarkId",
z.string().openapi({
@@ -240,3 +252,83 @@ registry.registerPath({
},
},
});
+
+registry.registerPath({
+ method: "post",
+ path: "/bookmarks/{bookmarkId}/assets",
+ description: "Attach a new asset to a bookmark",
+ summary: "Attach asset",
+ tags: ["Bookmarks"],
+ security: [{ [BearerAuth.name]: [] }],
+ request: {
+ params: z.object({ bookmarkId: BookmarkIdSchema }),
+ body: {
+ description: "The asset to attach",
+ content: {
+ "application/json": {
+ schema: zAssetSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: "The attached asset",
+ content: {
+ "application/json": {
+ schema: zAssetSchema,
+ },
+ },
+ },
+ },
+});
+
+registry.registerPath({
+ method: "put",
+ path: "/bookmarks/{bookmarkId}/assets/{assetId}",
+ description: "Replace an existing asset with a new one",
+ summary: "Replace asset",
+ tags: ["Bookmarks"],
+ security: [{ [BearerAuth.name]: [] }],
+ request: {
+ params: z.object({
+ bookmarkId: BookmarkIdSchema,
+ assetId: AssetIdSchema,
+ }),
+ body: {
+ description: "The new asset to replace with",
+ content: {
+ "application/json": {
+ schema: z.object({
+ assetId: z.string(),
+ }),
+ },
+ },
+ },
+ },
+ responses: {
+ 204: {
+ description: "No content - asset was replaced successfully",
+ },
+ },
+});
+
+registry.registerPath({
+ method: "delete",
+ path: "/bookmarks/{bookmarkId}/assets/{assetId}",
+ description: "Detach an asset from a bookmark",
+ summary: "Detach asset",
+ tags: ["Bookmarks"],
+ security: [{ [BearerAuth.name]: [] }],
+ request: {
+ params: z.object({
+ bookmarkId: BookmarkIdSchema,
+ assetId: AssetIdSchema,
+ }),
+ },
+ responses: {
+ 204: {
+ description: "No content - asset was detached successfully",
+ },
+ },
+});
diff --git a/packages/sdk/src/hoarder-api.d.ts b/packages/sdk/src/hoarder-api.d.ts
index fbe345d0..8aaeb503 100644
--- a/packages/sdk/src/hoarder-api.d.ts
+++ b/packages/sdk/src/hoarder-api.d.ts
@@ -349,6 +349,143 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/bookmarks/{bookmarkId}/assets": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Attach asset
+ * @description Attach a new asset to a bookmark
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ bookmarkId: components["parameters"]["BookmarkId"];
+ };
+ cookie?: never;
+ };
+ /** @description The asset to attach */
+ requestBody?: {
+ content: {
+ "application/json": {
+ id: string;
+ /** @enum {string} */
+ assetType:
+ | "screenshot"
+ | "bannerImage"
+ | "fullPageArchive"
+ | "video"
+ | "bookmarkAsset"
+ | "unknown";
+ };
+ };
+ };
+ responses: {
+ /** @description The attached asset */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ id: string;
+ /** @enum {string} */
+ assetType:
+ | "screenshot"
+ | "bannerImage"
+ | "fullPageArchive"
+ | "video"
+ | "bookmarkAsset"
+ | "unknown";
+ };
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/bookmarks/{bookmarkId}/assets/{assetId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /**
+ * Replace asset
+ * @description Replace an existing asset with a new one
+ */
+ put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ bookmarkId: components["parameters"]["BookmarkId"];
+ assetId: components["parameters"]["AssetId"];
+ };
+ cookie?: never;
+ };
+ /** @description The new asset to replace with */
+ requestBody?: {
+ content: {
+ "application/json": {
+ assetId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description No content - asset was replaced successfully */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ post?: never;
+ /**
+ * Detach asset
+ * @description Detach an asset from a bookmark
+ */
+ delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ bookmarkId: components["parameters"]["BookmarkId"];
+ assetId: components["parameters"]["AssetId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description No content - asset was detached successfully */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/lists": {
parameters: {
query?: never;
@@ -1001,6 +1138,8 @@ export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @example ieidlxygmwj87oxz5hxttoc8 */
+ AssetId: string;
+ /** @example ieidlxygmwj87oxz5hxttoc8 */
BookmarkId: string;
/** @example ieidlxygmwj87oxz5hxttoc8 */
ListId: string;
@@ -1119,6 +1258,7 @@ export interface components {
};
responses: never;
parameters: {
+ AssetId: components["schemas"]["AssetId"];
BookmarkId: components["schemas"]["BookmarkId"];
ListId: components["schemas"]["ListId"];
TagId: components["schemas"]["TagId"];