# Image URL Fetching Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `POST /api/images/from-url` endpoint that fetches an image from a URL, saves it locally, and returns the filename. Also add `imageSourceUrl` column to items and candidates. **Architecture:** New image service function handles URL fetching with validation (content-type, size, timeout). New route delegates to service. Schema changes add nullable `imageSourceUrl` to items and threadCandidates tables. Drizzle migration for the new column. **Tech Stack:** Hono routes, Zod validation, Drizzle ORM, Bun's native fetch, Bun's file I/O --- ## File Structure | Action | Path | Responsibility | |--------|------|----------------| | Create | `src/server/services/image.service.ts` | Image fetching logic (fetch URL, validate, save to disk) | | Modify | `src/server/routes/images.ts` | Add `POST /from-url` route | | Modify | `src/db/schema.ts` | Add `imageSourceUrl` to `items` and `threadCandidates` | | Modify | `src/shared/schemas.ts` | Add `imageSourceUrl` to item/candidate Zod schemas | | Modify | `src/server/services/item.service.ts` | Pass through `imageSourceUrl` in create/update | | Modify | `src/server/services/thread.service.ts` | Pass through `imageSourceUrl` in candidate create/update and thread resolution | | Modify | `tests/helpers/db.ts` | Add `image_source_url` column to test CREATE TABLE statements | | Create | `tests/services/image.service.test.ts` | Tests for image fetching service | | Create | `tests/routes/images.test.ts` | Route-level tests for `/api/images/from-url` | --- ### Task 1: Add `imageSourceUrl` Column to Database Schema **Files:** - Modify: `src/db/schema.ts:12-29` (items table) - Modify: `src/db/schema.ts:47-71` (threadCandidates table) - Modify: `tests/helpers/db.ts:19-32` (items CREATE TABLE) - Modify: `tests/helpers/db.ts:46-64` (thread_candidates CREATE TABLE) - [ ] **Step 1: Add column to Drizzle items table** In `src/db/schema.ts`, add after the `imageFilename` line in the `items` table: ```typescript imageSourceUrl: text("image_source_url"), ``` - [ ] **Step 2: Add column to Drizzle threadCandidates table** In `src/db/schema.ts`, add after the `imageFilename` line in the `threadCandidates` table: ```typescript imageSourceUrl: text("image_source_url"), ``` - [ ] **Step 3: Update test helper — items table** In `tests/helpers/db.ts`, add to the items CREATE TABLE statement after `image_filename TEXT,`: ```sql image_source_url TEXT, ``` - [ ] **Step 4: Update test helper — thread_candidates table** In `tests/helpers/db.ts`, add to the thread_candidates CREATE TABLE statement after `image_filename TEXT,`: ```sql image_source_url TEXT, ``` - [ ] **Step 5: Generate Drizzle migration** Run: `bun run db:generate` Expected: A new migration file created in `drizzle/` adding `image_source_url` to both tables. - [ ] **Step 6: Apply migration** Run: `bun run db:push` Expected: Migration applied successfully. - [ ] **Step 7: Run existing tests to verify no regressions** Run: `bun test` Expected: All existing tests pass. - [ ] **Step 8: Commit** ```bash git add src/db/schema.ts tests/helpers/db.ts drizzle/ git commit -m "feat: add imageSourceUrl column to items and threadCandidates" ``` --- ### Task 2: Update Zod Schemas and Service Functions **Files:** - Modify: `src/shared/schemas.ts:1-16` (item schemas) - Modify: `src/shared/schemas.ts:47-60` (candidate schemas) - Modify: `src/server/services/item.service.ts:50-71` (createItem) - Modify: `src/server/services/item.service.ts:73-101` (updateItem) - [ ] **Step 1: Add imageSourceUrl to createItemSchema** In `src/shared/schemas.ts`, add after the `imageFilename` line in `createItemSchema`: ```typescript imageSourceUrl: z.string().url().optional().or(z.literal("")), ``` - [ ] **Step 2: Add imageSourceUrl to createCandidateSchema** In `src/shared/schemas.ts`, add after the `imageFilename` line in `createCandidateSchema`: ```typescript imageSourceUrl: z.string().url().optional().or(z.literal("")), ``` - [ ] **Step 3: Update item service createItem** In `src/server/services/item.service.ts`, update the `createItem` function's `.values()` to include: ```typescript imageSourceUrl: data.imageSourceUrl ?? null, ``` - [ ] **Step 4: Update item service updateItem** In `src/server/services/item.service.ts`, add `imageSourceUrl: string` to the `Partial<{...}>` type in `updateItem`. - [ ] **Step 5: Update item service getAllItems and getItemById** Add `imageSourceUrl: items.imageSourceUrl` to the `.select()` objects in both `getAllItems` and `getItemById`. - [ ] **Step 6: Update thread service candidate create/update** In `src/server/services/thread.service.ts`, find the candidate create and update functions. Add `imageSourceUrl` passthrough in the same pattern as `imageFilename`. Also ensure that when resolving a thread (copying candidate data to a new item), `imageSourceUrl` is copied from the winning candidate. - [ ] **Step 7: Run tests** Run: `bun test` Expected: All tests pass. - [ ] **Step 8: Commit** ```bash git add src/shared/schemas.ts src/server/services/item.service.ts src/server/services/thread.service.ts git commit -m "feat: add imageSourceUrl to Zod schemas and service functions" ``` --- ### Task 3: Create Image Fetching Service **Files:** - Create: `src/server/services/image.service.ts` - Create: `tests/services/image.service.test.ts` - [ ] **Step 1: Write failing test — successful URL fetch** Create `tests/services/image.service.test.ts`: ```typescript import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { fetchImageFromUrl } from "../../src/server/services/image.service"; const TEST_UPLOADS_DIR = "test-uploads"; afterEach(() => { if (existsSync(TEST_UPLOADS_DIR)) { rmSync(TEST_UPLOADS_DIR, { recursive: true }); } }); describe("fetchImageFromUrl", () => { test("fetches a valid image URL and saves to disk", async () => { // Use a small, reliable test image const url = "https://via.placeholder.com/10x10.png"; const result = await fetchImageFromUrl(url, TEST_UPLOADS_DIR); expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/); expect(result.sourceUrl).toBe(url); expect(existsSync(join(TEST_UPLOADS_DIR, result.filename))).toBe(true); }); test("rejects non-image content type", async () => { const url = "https://example.com/"; await expect(fetchImageFromUrl(url, TEST_UPLOADS_DIR)).rejects.toThrow( "Invalid content type" ); }); test("rejects invalid URL", async () => { await expect(fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR)).rejects.toThrow(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test tests/services/image.service.test.ts` Expected: FAIL — module not found. - [ ] **Step 3: Implement image service** Create `src/server/services/image.service.ts`: ```typescript import { randomUUID } from "node:crypto"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const MAX_SIZE = 5 * 1024 * 1024; // 5MB const FETCH_TIMEOUT = 10_000; // 10 seconds interface FetchImageResult { filename: string; sourceUrl: string; } export async function fetchImageFromUrl( url: string, uploadsDir = "uploads", ): Promise { // Validate URL format let parsedUrl: URL; try { parsedUrl = new URL(url); } catch { throw new Error("Invalid URL format"); } if (!["http:", "https:"].includes(parsedUrl.protocol)) { throw new Error("URL must use HTTP or HTTPS"); } // Fetch with timeout const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); let response: Response; try { response = await fetch(url, { signal: controller.signal }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { throw new Error("Request timed out"); } throw new Error(`Failed to fetch image: ${(err as Error).message}`); } finally { clearTimeout(timeout); } if (!response.ok) { throw new Error(`HTTP ${response.status}: Failed to fetch image`); } // Validate content type const contentType = response.headers.get("content-type")?.split(";")[0].trim(); if (!contentType || !ALLOWED_TYPES.includes(contentType)) { throw new Error( `Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`, ); } // Check content length if available const contentLength = response.headers.get("content-length"); if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) { throw new Error("File too large. Maximum size is 5MB"); } // Read body and check actual size const buffer = await response.arrayBuffer(); if (buffer.byteLength > MAX_SIZE) { throw new Error("File too large. Maximum size is 5MB"); } // Determine extension const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1]; const filename = `${Date.now()}-${randomUUID()}.${ext}`; // Ensure directory exists and write await mkdir(uploadsDir, { recursive: true }); await Bun.write(join(uploadsDir, filename), buffer); return { filename, sourceUrl: url }; } ``` - [ ] **Step 4: Run tests** Run: `bun test tests/services/image.service.test.ts` Expected: All 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/server/services/image.service.ts tests/services/image.service.test.ts git commit -m "feat: add image URL fetching service with tests" ``` --- ### Task 4: Add Route for URL Image Fetching **Files:** - Modify: `src/server/routes/images.ts` - Create: `tests/routes/images.test.ts` - [ ] **Step 1: Write failing route test** Create `tests/routes/images.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { imageRoutes } from "../../src/server/routes/images"; const app = new Hono(); app.route("/api/images", imageRoutes); describe("POST /api/images/from-url", () => { test("returns 400 for missing URL", async () => { const res = await app.request("/api/images/from-url", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(400); }); test("returns 400 for invalid URL", async () => { const res = await app.request("/api/images/from-url", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: "not-a-url" }), }); expect(res.status).toBe(400); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test tests/routes/images.test.ts` Expected: FAIL — route not found (404). - [ ] **Step 3: Add the from-url route** In `src/server/routes/images.ts`, add imports and the new route: ```typescript import { randomUUID } from "node:crypto"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { fetchImageFromUrl } from "../services/image.service"; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const MAX_SIZE = 5 * 1024 * 1024; // 5MB const app = new Hono(); const fromUrlSchema = z.object({ url: z.string().url("Invalid URL"), }); app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => { const { url } = c.req.valid("json"); try { const result = await fetchImageFromUrl(url); return c.json(result, 201); } catch (err) { return c.json({ error: (err as Error).message }, 400); } }); // Existing file upload route stays below app.post("/", async (c) => { // ... existing code unchanged ... }); ``` Note: Keep the existing `app.post("/", ...)` handler exactly as-is. Just add the new `/from-url` route above it. - [ ] **Step 4: Run tests** Run: `bun test tests/routes/images.test.ts` Expected: Both tests pass. - [ ] **Step 5: Run all tests** Run: `bun test` Expected: All tests pass. - [ ] **Step 6: Commit** ```bash git add src/server/routes/images.ts tests/routes/images.test.ts git commit -m "feat: add POST /api/images/from-url route" ```