Replace the presigned-URL redirect (302) in GET /api/v1/images/{id}/file
with a direct proxy that fetches bytes from S3 server-side and returns them
to the client. The browser never contacts the storage backend, eliminating
the /etc/hosts workaround needed in local development.
- StorageBackend: swap get_presigned_url for get(key) -> bytes
- S3StorageBackend: implement get() via aiobotocore get_object
- serve_image_file: return Response with ETag + Cache-Control: immutable
- test_serving: assert 200 + content-type + ETag; add no-storage-details test
- Spec Kit artifacts for feature 002-api-image-proxy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.0 KiB
Implementation Plan: API Image Proxy
Branch: 002-api-image-proxy | Date: 2026-05-03 | Spec: spec.md
Input: Feature specification from specs/002-api-image-proxy/spec.md
Summary
Replace the presigned-URL redirect in GET /api/v1/images/{id}/file with a direct
proxy that fetches the image from object storage server-side and streams the bytes
back to the client. The browser never contacts the storage backend. This eliminates
the /etc/hosts workaround required in local development and keeps the storage
layer fully private.
Changes touch three files in the API and the integration test suite. The UI requires no changes because the endpoint path is unchanged.
Technical Context
Language/Version: Python 3.12+ (API); TypeScript strict mode (UI — no changes)
Primary Dependencies: FastAPI, aiobotocore (S3 retrieval), pytest-asyncio (tests)
Storage: S3-compatible object storage (MinIO locally, real S3 in production) via StorageBackend interface
Testing: pytest + pytest-asyncio; existing integration test fixture reused
Target Platform: Linux server (containerised); modern evergreen desktop browsers
Project Type: Web application — FastAPI API + Angular SPA
Performance Goals: Image load time via proxy within 20% of direct storage access on local network
Constraints: Max 50 MB per image; no storage details exposed in any error response
Scale/Scope: Single-user personal application; no concurrency or throughput targets for v1
Constitution Check
GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.
| Principle | Check | Status |
|---|---|---|
| §2.1 Separation of concerns | API proxies bytes; UI is unchanged; storage detail never reaches the browser | ✅ |
| §2.2 Dependency direction | UI → API → Storage; no new upward imports introduced | ✅ |
| §2.3 Storage abstraction | Retrieval goes through the StorageBackend.get() method; no raw S3 calls in the router |
✅ |
| §2.4 Auth abstraction | No change to auth flow | ✅ |
| §2.5 DB abstraction | No query logic moves outside repositories | ✅ |
| §2.6 No speculative abstraction | get_presigned_url removed (no callers); get() added (has one caller) |
✅ |
| §3.1 API versioning | Endpoint path /api/v1/images/{id}/file is unchanged |
✅ |
| §3.3 Error shape | Storage errors return {"detail": "...", "code": "storage_error"} |
✅ |
| §5.1 TDD non-negotiable | Failing tests written before implementation in every task | ✅ |
| §5.2 Test pyramid | Existing integration test updated; no unit test needed (thin router delegation) | ✅ |
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
Post-design re-check: Contracts and data model confirmed above; all gates still pass.
Project Structure
Documentation (this feature)
specs/002-api-image-proxy/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 decisions
├── data-model.md # Interface change summary
├── contracts/
│ └── api.md # Updated endpoint contract
├── checklists/
│ └── requirements.md # Spec quality checklist
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
Files changed
api/
├── app/
│ ├── storage/
│ │ ├── backend.py # Remove get_presigned_url; add get(key) -> bytes
│ │ └── s3_backend.py # Remove get_presigned_url impl; add get(key) -> bytes
│ └── routers/
│ └── images.py # serve_image_file: RedirectResponse → Response with bytes + cache headers
└── tests/
└── integration/
└── test_serving.py # T055: update assertion from 302 to 200 with content-type + ETag
No UI files change. ImageService.getFileUrl() already returns /api/v1/images/{id}/file.
Milestones
TDD ORDER IS MANDATORY (constitution §5.1): Write the failing test first, confirm it fails, then implement until it passes.
M1 — Update StorageBackend interface and S3 implementation
Goal: The storage layer can retrieve object bytes; presigned-URL generation is removed.
Tasks (TDD order):
-
In
api/app/storage/backend.py:- Remove the
get_presigned_urlabstract method - Add
async def get(self, key: str) -> bytesas a new abstract method
- Remove the
-
In
api/app/storage/s3_backend.py:- Remove the
get_presigned_urlimplementation - Add
async def get(self, key: str) -> bytes:async with self._client() as client: response = await client.get_object( Bucket=self._settings.s3_bucket_name, Key=key, ) return await response["Body"].read()
- Remove the
-
Verify:
ruff check api/passes;mypyor pyright (if configured) passes.
Done criterion: Interface and implementation compile cleanly; no existing tests break.
M2 — Update the integration test (failing first)
Goal: T055 asserts the new contract (200 + bytes) before any router change.
Tasks (TDD order):
-
In
api/tests/integration/test_serving.py, updatetest_file_redirect_returns_302:- Rename to
test_file_returns_200_with_content - Change assertion from
status_code == 302andLocationheader to:response.status_code == 200response.headers["content-type"]starts with"image/"response.headers["etag"]is present and equalsf'"{image.hash}"'response.headers["cache-control"]contains"immutable"len(response.content) > 0
- Remove
follow_redirects=Falsefrom the client call
- Rename to
-
Run
pytest api/tests/integration/test_serving.py— confirm T055 fails (still 302).
Done criterion: Test file updated; new test fails with the current implementation.
M3 — Update the router
Goal: serve_image_file proxies bytes from storage instead of redirecting.
Tasks (TDD order — tests already written in M2):
-
In
api/app/routers/images.py:-
Remove
from fastapi.responses import RedirectResponseimport -
Add
from fastapi.responses import Response(already present asResponsefrom fastapi) -
Update
serve_image_file:@router.get("/images/{image_id}/file") async def serve_image_file( image_id: uuid.UUID, db: AsyncSession = Depends(get_db), storage: StorageBackend = Depends(get_storage), ): image_repo = ImageRepository(db) image = await image_repo.get_by_id(image_id) if not image: raise HTTPException( status_code=404, detail={"detail": "Image not found", "code": "image_not_found"}, ) try: data = await storage.get(image.storage_key) except Exception: raise HTTPException( status_code=500, detail={"detail": "Failed to retrieve image content", "code": "storage_error"}, ) return Response( content=data, media_type=image.mime_type, headers={ "ETag": f'"{image.hash}"', "Cache-Control": "public, max-age=31536000, immutable", }, )
-
-
Run
pytest api/tests/integration/test_serving.py— confirm all tests pass. -
Run full test suite:
pytest api/— confirm no regressions. -
Run
ruff check api/ && ruff format --check api/.
Done criterion: All 45+ tests pass; linter clean; the presigned-URL redirect is gone.
Post-design Constitution Re-check
| Principle | Verdict |
|---|---|
| §2.3 Storage abstraction | get() added to interface; router only calls storage.get() |
| §2.6 No speculative abstraction | get_presigned_url removed with zero callers remaining |
| §3.3 Error shape | storage_error code used for retrieval failures |
| §5.1 TDD | Test updated to fail before router change |
All gates pass. Feature is ready for /speckit-tasks.