# Research: API Image Proxy **Branch**: `002-api-image-proxy` | **Date**: 2026-05-03 ## Decision 1: Storage retrieval method **Decision**: Add `async def get(key: str) -> bytes` to `StorageBackend` and remove `get_presigned_url`. **Rationale**: The only consumer of `get_presigned_url` is the `serve_image_file` route. Once the route stops redirecting and starts streaming bytes directly, `get_presigned_url` has no call sites. Per §2.6 (no speculative abstraction), it must be removed. A simple `get → bytes` method is consistent with `put` which already operates on `bytes`, and is straightforward to implement and test. At 50 MB maximum file size, loading the full object into memory in a single call is acceptable — the same pattern is used on upload already. **Alternatives considered**: - `async def stream(key) -> AsyncIterator[bytes]`: True streaming avoids buffering but complicates the abstract interface (async generators cannot cleanly implement abstract methods in Python without workarounds). Deferred; can be introduced later if memory pressure is observed. - Keep `get_presigned_url` and add `get`: Violates §2.6 since `get_presigned_url` would then have no callers. --- ## Decision 2: HTTP response type for the proxy endpoint **Decision**: Return `fastapi.Response(content=data, media_type=mime_type)` with the image bytes directly. **Rationale**: FastAPI's `Response` with raw bytes is the simplest correct approach when the full content is already in memory. The `mime_type` field is already stored on the `Image` database record, so the router can set `Content-Type` from it without re-inspecting the file. **Alternatives considered**: - `StreamingResponse` with an async generator: Appropriate for true streaming but adds complexity with no benefit when content is already loaded as `bytes`. - `FileResponse`: For local file paths only, not applicable. --- ## Decision 3: Caching headers **Decision**: Add `ETag: ""` and `Cache-Control: public, max-age=31536000, immutable` to the content response. **Rationale**: Image files are immutable after upload (constitution §4.2). The SHA-256 hash is already stored on the `Image` record. An `ETag` allows conditional `GET` requests (`If-None-Match`) so browsers skip re-downloading unchanged content. `Cache-Control: immutable` tells browsers the content will never change for this URL, eliminating speculative revalidation. Together these satisfy SC-004 (browser caching). **Alternatives considered**: - `Last-Modified` header: Less precise than ETag for binary content. - No caching headers: Fails SC-004. --- ## Decision 4: Endpoint path **Decision**: Keep the existing path `GET /api/v1/images/{image_id}/file`. Change the response from `302 RedirectResponse` to `200` with binary content. **Rationale**: The path already exists, is already referenced by the UI's `getFileUrl()` method, and appears in the existing OpenAPI contract. Changing the response body but not the path means the UI requires no URL-construction changes. The existing integration tests (`test_serving.py`) must be updated to assert `200` instead of `302`. **Alternatives considered**: - New path `/api/v1/images/{id}/content`: Would require UI changes with no benefit; the existing path already semantically expresses "the file content for this image". --- ## Decision 5: Removal of `storage_key` from public API response **Decision**: Out of scope for this feature. `storage_key` in the image metadata response is an internal S3 key (SHA-256 hex string), but it is not an actionable credential or hostname. Removal is a separate API-breaking change. **Rationale**: The spec explicitly scopes this feature to how image *content* is delivered. Metadata response shape changes are independent and require their own spec and contract update.