Files
agatha cd89ba5dea Feat: Proxy image content through the API instead of redirecting to MinIO
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>
2026-05-03 16:36:43 +00:00

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):

  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:

      @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.