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>
196 lines
8.0 KiB
Markdown
196 lines
8.0 KiB
Markdown
# Implementation Plan: API Image Proxy
|
|
|
|
**Branch**: `002-api-image-proxy` | **Date**: 2026-05-03 | **Spec**: [spec.md](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)
|
|
|
|
```text
|
|
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
|
|
|
|
```text
|
|
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):
|
|
|
|
1. In `api/app/storage/backend.py`:
|
|
- Remove the `get_presigned_url` abstract method
|
|
- Add `async def get(self, key: str) -> bytes` as a new abstract method
|
|
|
|
2. In `api/app/storage/s3_backend.py`:
|
|
- Remove the `get_presigned_url` implementation
|
|
- 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()
|
|
```
|
|
|
|
3. Verify: `ruff check api/` passes; `mypy` or 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):
|
|
|
|
1. In `api/tests/integration/test_serving.py`, update `test_file_redirect_returns_302`:
|
|
- Rename to `test_file_returns_200_with_content`
|
|
- Change assertion from `status_code == 302` and `Location` header to:
|
|
- `response.status_code == 200`
|
|
- `response.headers["content-type"]` starts with `"image/"`
|
|
- `response.headers["etag"]` is present and equals `f'"{image.hash}"'`
|
|
- `response.headers["cache-control"]` contains `"immutable"`
|
|
- `len(response.content) > 0`
|
|
- Remove `follow_redirects=False` from the client call
|
|
|
|
2. 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):
|
|
|
|
1. In `api/app/routers/images.py`:
|
|
- Remove `from fastapi.responses import RedirectResponse` import
|
|
- Add `from fastapi.responses import Response` (already present as `Response` from fastapi)
|
|
- Update `serve_image_file`:
|
|
|
|
```python
|
|
@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",
|
|
},
|
|
)
|
|
```
|
|
|
|
2. Run `pytest api/tests/integration/test_serving.py` — confirm all tests pass.
|
|
3. Run full test suite: `pytest api/` — confirm no regressions.
|
|
4. 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`.
|