diff options
| -rw-r--r-- | apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts | 37 | ||||
| -rw-r--r-- | apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts | 36 | ||||
| -rw-r--r-- | packages/e2e_tests/tests/api/assets.test.ts | 162 | ||||
| -rw-r--r-- | packages/open-api/hoarder-openapi-spec.json | 163 | ||||
| -rw-r--r-- | packages/open-api/lib/bookmarks.ts | 92 | ||||
| -rw-r--r-- | packages/sdk/src/hoarder-api.d.ts | 140 |
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"]; |
