# 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: "<sha256-hash>"` 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.
