API responses now include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these point to the CDN domain; when unset, they fall back to the existing API proxy paths so local dev requires no additional setup. UI updated to use response URL fields directly instead of constructing proxy URLs client-side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.9 KiB
Implementation Plan: CDN Image Serving
Branch: 014-r2-cdn-serving | Date: 2026-05-08 | Spec: spec.md
Input: Feature specification from specs/014-r2-cdn-serving/spec.md
Summary
Extend the image metadata API response to include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these fields contain CDN URLs pointing directly to Cloudflare R2. When unconfigured, they fall back to the existing API proxy paths so local development requires no setup changes. The UI is updated to use these response fields instead of constructing proxy URLs client-side. Proxy endpoints are retained unchanged.
Technical Context
Language/Version: Python 3.12 (API), TypeScript strict mode (UI) Primary Dependencies: FastAPI, SQLAlchemy 2.x async, Angular (latest stable), pydantic-settings Storage: PostgreSQL (image metadata), S3-compatible object storage (R2 in production, MinIO in dev) Testing: pytest (unit + integration), Angular component tests Target Platform: Linux (k3s), local Docker Compose Project Type: Web service (API) + SPA (UI) Performance Goals: No additional latency on API responses; image load latency reduced by eliminating API proxy hop in production Constraints: No breaking changes to existing API response fields; proxy endpoints must remain functional Scale/Scope: Single-owner app; ~100 existing images migrated to R2 prior to this feature
Constitution Check
| Principle | Status | Notes |
|---|---|---|
| §2.1 Strict separation of concerns | PASS | URL construction stays in router layer; storage backend unchanged |
| §2.3 Storage abstraction | PASS | No changes to StorageBackend interface or S3StorageBackend |
| §2.6 No speculative abstraction | PASS | No new interfaces introduced; URL logic is a simple helper |
§3.1 API versioning (/api/v1/) |
PASS | Adding fields to response is non-breaking per §3.1 |
| §3.2 OpenAPI as contract | PASS | New fields documented in contracts/image-response.md |
| §5.1 Tests alongside implementation | REQUIRED | Unit tests for URL construction; integration tests for response fields |
| §7.2 Environment configuration | PASS | S3_PUBLIC_BASE_URL via env var; no hardcoded URLs |
No constitution violations. All gates pass.
Project Structure
Documentation (this feature)
specs/014-r2-cdn-serving/
├── plan.md # This file
├── research.md # Technical decisions
├── contracts/
│ └── image-response.md # Updated image response schema
├── quickstart.md # Integration test scenarios
└── tasks.md # Phase 2 output (speckit-tasks)
Source Code Changes
api/
├── app/
│ ├── config.py # Add: s3_public_base_url: str | None = None
│ └── routers/
│ └── images.py # Update: _image_to_dict gains cdn_base param;
│ # add file_url + thumbnail_url to response;
│ # pass cdn_base from get_settings() at endpoint level
├── tests/
│ ├── unit/
│ │ └── test_url_construction.py # New: pure unit tests for URL logic
│ └── integration/
│ └── test_images.py # Update: assert file_url + thumbnail_url present in responses
ui/src/app/
├── services/
│ └── image.service.ts # Update: add file_url/thumbnail_url to ImageRecord;
│ # remove getFileUrl()/getThumbnailUrl() methods
├── library/
│ └── library.component.ts # Update: use img.thumbnail_url instead of getThumbnailUrl(img.id)
└── detail/
└── detail.component.ts # Update: use img.file_url instead of getFileUrl(img.id)
.env.example # Add: S3_PUBLIC_BASE_URL= (empty = local dev proxy fallback)
Key Implementation Details
URL construction logic (api/app/routers/images.py)
_image_to_dict gains a cdn_base: str | None parameter:
def _image_to_dict(image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None):
base = cdn_base.rstrip("/") if cdn_base else None
file_url = f"{base}/{image.storage_key}" if base else f"/api/v1/images/{image.id}/file"
thumbnail_url = (
(f"{base}/{image.thumbnail_key}" if base else f"/api/v1/images/{image.id}/thumbnail")
if image.thumbnail_key else None
)
return {
..., # existing fields unchanged
"file_url": file_url,
"thumbnail_url": thumbnail_url,
}
Each endpoint calls get_settings() once and passes settings.s3_public_base_url as cdn_base.
Config addition (api/app/config.py)
s3_public_base_url: str | None = None
No validator needed — None is the valid "not configured" state.
UI changes (ui/src/app/services/image.service.ts)
ImageRecord gains two new fields:
file_url: string;
thumbnail_url: string | null;
getFileUrl(id) and getThumbnailUrl(id) methods are removed. Components use image.file_url and image.thumbnail_url directly.
Phase Breakdown
Phase 1: API — config + URL construction (US1 foundation)
- Add
s3_public_base_urlto config - Update
_image_to_dictwithcdn_baseparameter - Update all call sites to pass
cdn_basefrom settings - Unit tests for URL construction (both CDN and fallback paths)
- Integration tests verifying
file_url/thumbnail_urlin all image responses
Phase 2: UI — consume response URLs (US1 + US2)
- Update
ImageRecordinterface - Remove
getFileUrl/getThumbnailUrlmethods from service - Update library component
- Update detail component
- Update service tests
Phase 3: Config + docs
- Add
S3_PUBLIC_BASE_URLto.env.example - Manual end-to-end verification (local dev + production)