Backend Architecture¶
Overview¶
The backend is a FastAPI application providing email services and API endpoints.
Technology Stack¶
- Framework: FastAPI
- Language: Python 3.11
- ASGI Server: Uvicorn
- Validation: Pydantic v2 + pydantic-settings
- Email: SMTP (Gmail) via Jinja2-rendered HTML templates
- Rate Limiting: slowapi (in-memory, keyed by IP; single-replica only)
- Database ORM: SQLAlchemy 2 (async), asyncpg driver
- Migrations: Alembic
- Logging: loguru (structured JSON in production, pretty in dev)
Project Structure¶
The backend follows an ArjanCodes-inspired layered architecture (PRs #370, #375, #377, #379, #380, #381):
core → schemas → repositories → services → routers. main.py is now a
~150-line create_app() factory that registers routers, middleware, and exception handlers.
See Architecture Pattern below for the design rationale.
api/
├── main.py # ~150-line create_app() factory
├── feature_flags.py # Azure App Config feature flag cache (30 s TTL)
├── requirements.txt # Python dependencies
├── __init__.py
├── core/
│ ├── config.py # Settings (pydantic-settings); `settings` singleton imported everywhere
│ ├── exceptions.py # PortfolioError, NotFoundError, ConflictError, ServiceUnavailableError
│ │ # + register_exception_handlers()
│ ├── limiter.py # Shared slowapi Limiter instance (in-memory, single-replica)
│ ├── logging.py # configure_logging() — loguru + InterceptHandler for stdlib/uvicorn
│ ├── middleware.py # RequestIdMiddleware — reads/generates X-Request-Id header
│ └── openapi.py # custom_openapi() — x-uigen-id / x-uigen-view annotations
├── db/
│ ├── engine.py # Async engine + get_db() + get_optional_db()
│ └── models.py # Project, ProjectPhase, WorkEntry, SkillCategory, Skill,
│ # Award, Certification, UigenToken
├── routers/
│ ├── admin.py # Write endpoints (bearer-token protected), mounted at /api
│ ├── awards.py # GET /api/awards
│ ├── certifications.py # GET /api/certifications
│ ├── contact.py # POST /api/contact
│ ├── cv.py # GET /api/cv/status, PATCH /api/cv/generate
│ ├── experience.py # GET /api/experience[/{index}]
│ ├── feature_flags.py # GET + PATCH /api/feature-flags
│ ├── health.py # GET /health (infra probe), GET /api/health
│ ├── projects.py # GET /api/projects[/{slug}]
│ ├── skills.py # GET /api/skills
│ └── uigen.py # GET /api/uigen-token
├── repositories/
│ ├── awards.py # list_awards(db) — DB or seed fallback
│ ├── certifications.py # list_certifications(db) — DB or seed fallback
│ ├── experience.py # list_entries(db), get_by_id(db, id) — DB or seed fallback
│ ├── projects.py # list_cards(db), get_by_slug(db, slug) — selectinload(Project.phases)
│ └── skills.py # list_categories(db) — DB or seed fallback
├── schemas/
│ ├── experience.py # Create/update request schemas for experience
│ ├── projects.py # Create/update request schemas + ProjectPhaseCreate
│ ├── responses.py # 11 Pydantic v2 response schemas (from_attributes=True):
│ │ # ProjectCard, ProjectDetail, ProjectPhase, WorkEntry, Skill,
│ │ # SkillCategory, Award, Certification, ContactResponse,
│ │ # HealthResponse, FeatureFlagsResponse
│ └── skills.py # Create/update request schemas for skills
├── services/
│ ├── cv.py # generate_cv() via asyncio.create_subprocess_exec
│ ├── email.py # EmailService (Jinja2 + SMTP)
│ └── uigen_tokens.py # issue(), validate(), revoke() — SHA-256 hashed, DB-backed
├── templates/
│ ├── contact_admin.html # Jinja2 template — notification email to portfolio owner
│ └── contact_user.html # Jinja2 template — confirmation email to form submitter
└── data/
├── seed.json # Canonical static fallback for all content (projects, experience,
│ # skills, awards, certifications)
└── seed.py # lru_cache loader: load_projects(), load_work_entries(), etc.
alembic/
└── versions/
├── 0001_initial_schema.py
├── 0002_seed_initial_data.py
├── 0003_add_iac_platform_project.py
├── 0004_add_awards_certifications.py
├── 0005_project_phases_table.py # Replaces timeline_encoded ARRAY with child table
└── 0006_uigen_tokens_table.py # uigen_tokens table for DB-backed token store
API Endpoints¶
Health Check¶
Contact Form¶
CV Download¶
CV PDFs are copied into the container at build time (client/public/*.pdf → /app/public/). The lang query parameter selects English or Dutch. Defaults to english if omitted or unrecognised.
Projects¶
Experience¶
Skills¶
Awards and Certifications¶
Data for all groups is read from PostgreSQL via the SQLAlchemy async ORM (see Database section below). See API Endpoints for full request/response shapes.
Admin API¶
The admin write endpoints are protected by a bearer token enforced at the FastAPI router level via dependencies=[Depends(_require_admin)]. They share the same /api/ prefix as the read endpoints — there is no separate /api/admin/ prefix.
# Projects (admin)
POST /api/projects # create a project record
PUT /api/projects/{slug} # replace a project (full update)
PATCH /api/projects/{slug} # partial update a project
DELETE /api/projects/{slug} # delete a project
PATCH /api/projects/reorder # set sort order for all projects
# Experience (admin)
POST /api/experience # create a work entry
PUT /api/experience/{entry_id} # replace a work entry (full update)
PATCH /api/experience/{entry_id} # partial update a work entry
DELETE /api/experience/{entry_id} # delete a work entry
PATCH /api/experience/reorder # set sort order for all entries
# Skills (admin)
POST /api/skills/categories # create a skill category
PUT /api/skills/categories/{category_id} # replace a skill category
PATCH /api/skills/categories/{category_id} # partial update a skill category
DELETE /api/skills/categories/{category_id} # delete a skill category
POST /api/skills/categories/{category_id}/skills # add a skill to a category
PUT /api/skills/{skill_id} # replace a skill
PATCH /api/skills/{skill_id} # partial update a skill
DELETE /api/skills/{skill_id} # delete a skill
# All admin routes require: Authorization: Bearer <api-admin-token>
The admin token is stored in Azure Key Vault (api-admin-token secret) and injected as an environment variable via Bicep. These endpoints are used for seeding and updating database content without direct DB access.
The entry_id path parameter for experience is a UUID (matching the database primary key), not a 0-based index. The read endpoint GET /api/experience/{index} still uses a 0-based integer index.
UIGen Token¶
The AdminModal in the frontend calls this endpoint to get a scoped token, then opens https://admin.sven-relijveld.com/auth?token=<token>. The UIGen auth shim writes the token to sessionStorage["uigen_auth"] and redirects to UIGen root. Guest tokens expire after 1 hour.
Token storage is handled by api/services/uigen_tokens.py (issue(), validate(), revoke()). Tokens are stored as SHA-256 hashes in the uigen_tokens database table (migration 0006) when a DB is available, falling back to an in-process dict when the DB is unavailable (local dev / DB down). The DB-backed store survives container restarts and works correctly with multi-replica deployments; the in-process fallback is single-replica only.
Feature Flags¶
GET reads current flag values from Azure App Configuration (dna-portfolio-we-appcs). The backend uses managed identity (App Configuration Data Reader RBAC) with a 30-second TTL cache. Labels are environment-specific (dev/prod). Falls back to the FEATURE_CV_GENERATION env var if App Config is unavailable.
PATCH writes flag updates back to Azure App Configuration via ff.set_flag(). The admin bearer token is required. Both flags are optional in the request body — only supplied fields are updated.
| Flag | Key in App Config | Frontend effect |
|---|---|---|
cvGeneration |
cv-generation |
Shows CV generate button in CV download dialog |
portfolioContentApi |
portfolio-content-api |
Fetches projects/experience/skills from the API instead of static data |
CV Generation (feature-flagged)¶
The CV generation pipeline (cv/build.ts) uses Bun + pagedjs-cli to render a custom Nunjucks template into PDF. The PATCH method is used rather than GET because UIGen's view-hint classifier maps PATCH without a path parameter to an action button, which renders correctly as a generate button in the admin portal. Controlled by the cv-generation feature flag in Azure App Configuration (dev=on, prod=on as of v0.10.0).
Rate Limiting¶
All endpoints are rate-limited via slowapi (keyed by client IP). Limits: contact 5/min, CV download 10/min, read endpoints 60/min. Exceeding a limit returns 429 {"detail": "Rate limit exceeded"}.
Database¶
The backend uses Azure PostgreSQL Flexible Server (production) with SQLAlchemy 2 async and asyncpg as the driver. Alembic manages schema migrations.
Models¶
| Model | Table | Description |
|---|---|---|
Project |
projects |
Portfolio project cards and detail pages |
ProjectPhase |
project_phases |
Ordered timeline phases for a project (FK to projects, added in migration 0005) |
WorkEntry |
work_entries |
Professional experience timeline entries |
SkillCategory |
skill_categories |
Skill groups with icon names and color metadata |
Skill |
skills |
Individual skills, FK to skill_categories |
Award |
awards |
Awards and recognition entries |
Certification |
certifications |
Professional certifications with link and icon |
UigenToken |
uigen_tokens |
SHA-256 hashed UIGen guest/admin tokens with expiry (added in migration 0006) |
All tables use UUID primary keys generated by the database (gen_random_uuid()). Skill has a cascade-delete foreign key to SkillCategory. ProjectPhase has a cascade-delete foreign key to Project. Project.phases is loaded via selectinload to avoid N+1 queries on project list endpoints.
Connection¶
api/db/engine.py creates an async SQLAlchemy engine via _make_engine(database_url, use_managed_identity):
USE_MANAGED_IDENTITY=true(Azure): usesManagedIdentityCredentialfromazure-identityto fetch a short-lived AAD token and passes it as the asyncpgpasswordcallable. TheMANAGED_IDENTITY_CLIENT_IDenv var (set by Bicep) is passed toManagedIdentityCredential(client_id=...)to target the specific user-assigned identity.USE_MANAGED_IDENTITY=false(local): uses a plain username/password fromDATABASE_URL(docker-compose postgres service).
Important: The DATABASE_URL username must be the MI's principal name (e.g. dna-prd-portfolio-we-id-be), not its client ID UUID. PostgreSQL AAD authentication matches on the principal name. The value is written to Key Vault by the deploy workflow using the MI's principal name.
Two async generator dependencies are provided for route handlers:
get_db()— raisesServiceUnavailableError(→ 503) if the engine is not configured. Used by write endpoints (admin) and any read endpoint with no static fallback.get_optional_db()— yieldsAsyncSession | None; returnsNonewhen the engine is not configured. Used by read endpoints that fall back toapi/data/seed.jsonviaapi/data/seed.py.
Migrations¶
alembic/env.py uses _make_engine() (not a bare create_async_engine) so Alembic uses the same AAD token logic as the app runtime when USE_MANAGED_IDENTITY=true.
Architecture Pattern¶
The backend uses an ArjanCodes-inspired layered architecture. The dependency flow is strictly one-directional:
core (config, exceptions, logging, middleware)
↓
schemas (Pydantic v2 request + response models)
↓
repositories (DB queries; fall back to seed.json when DB unavailable)
↓
services (EmailService, CvService, UigenTokenService)
↓
routers (thin handlers: parse → call repo/service → return schema)
Key design decisions:
- No generic
Repository[T]ABC — each aggregate has plainasync deffunctions in its own module. Generic bases only pay off with multiple backend implementations; we have one (PostgreSQL via SQLAlchemy). - No DI container — FastAPI's
Depends()covers every injection need. - Domain exceptions translate at the edge — repositories raise
NotFoundError,ConflictError, etc.;register_exception_handlers()inapi/core/exceptions.pymaps them to HTTP status codes. Handlers focus on the happy path only. - DB-or-fallback in repositories, not in handlers — each read repository function accepts
db: AsyncSession | None; whenNone, it returns data fromapi/data/seed.jsonviaseed.py. Pydantic v2from_attributes=Trueschemas validate both ORM rows and plain dicts without a special case at the router edge. main.pyis a thin factory —create_app()registers routers, middleware, and exception handlers; module-level side effects are limited toapp = create_app().
See docs/planning/plan-api-arjan-refactor.md (archived) for the full rationale and ArjanCodes reference map.
Structured Logging and Request IDs¶
Logging (Phase 6, PR #381)¶
api/core/logging.py provides configure_logging(), called once in create_app():
- Installs an
InterceptHandleron the root stdlib logger so that uvicorn, SQLAlchemy, and all third-party libraries funnel through loguru. - In production (
settings.environment != "development"): JSON sink to stdout — structured output suitable for Azure Monitor / Log Analytics. - In development: pretty sink with color and human-readable timestamps.
Request ID Middleware (Phase 6, PR #381)¶
api/core/middleware.py provides RequestIdMiddleware:
- On each incoming request: reads
X-Request-Idfrom the request headers; if absent, generates a new UUID v4. - Binds the request ID to a
contextvars.ContextVarso it is accessible anywhere in the call stack (repositories, services) without explicit threading. - Echoes the ID back in the response
X-Request-Idheader. - Loguru's context filter pulls the current request ID into every log line emitted during request handling, enabling full log correlation by request ID in Log Analytics.
Usage: clients that set X-Request-Id on outbound requests (e.g. the frontend, load
balancers) will see their ID round-tripped in the response. Without a client-supplied ID the
middleware generates one transparently.
Email Service¶
Features¶
- Dual Emails: Notification + Confirmation
- HTML Templates: Professional branded emails
- Error Handling: Graceful fallbacks
- Logging: Comprehensive error logging
Email Flow¶
SMTP Configuration¶
Security¶
Secrets Management¶
- Azure Key Vault: Production secrets
- Environment Variables: Development
- No Hardcoding: All secrets externalized
CORS Configuration¶
Docker Build¶
FROM python:3.11-slim
WORKDIR /app
# Install gcc for psycopg2
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
COPY api/requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY api/ ./api/
# CV PDFs for /api/download-cv
COPY client/public/*.pdf ./public/
# Run as non-root user
RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run Alembic migrations, then start the server
CMD ["sh", "-c", "n=0; until alembic -c api/alembic.ini upgrade head; do n=$((n+1)); [ $n -ge 5 ] && echo 'Alembic failed after 5 attempts' && exit 1; echo \"Alembic attempt $n failed, retrying in 15s...\"; sleep 15; done && uvicorn api.main:app --host 0.0.0.0 --port 8000 --workers 1"]
Performance¶
- Async/Await: Non-blocking I/O throughout (FastAPI + SQLAlchemy async + asyncpg)
- Connection Pooling: SQLAlchemy async engine manages the DB connection pool; efficient SMTP connections for email
- Monitoring: Application Insights integration
Error Handling¶
SMTPAuthenticationError is caught and logged but does not fail the request — the API always returns success: true so the contact form shows a success message regardless of email delivery. This prevents credential misconfigurations from breaking the UX.
Testing¶
Tests are split across two files and share a conftest.py that sets required environment variables before api.main is imported.
tests/conftest.py — sets EMAIL_ADDRESS and EMAIL_PASSWORD defaults so pydantic-settings can instantiate without real credentials.
tests/test_api_endpoints.py — uses FastAPI's TestClient with smtplib.SMTP mocked via unittest.mock.patch. A reset_rate_limiter autouse fixture clears slowapi state before each test. No running server is required.
| Test | What it asserts |
|---|---|
test_health |
GET /api/health → 200, status: healthy |
test_contact_valid |
POST /api/contact → 200, success: true |
test_contact_with_optional_company |
Company field accepted |
test_contact_invalid_email |
Invalid email → 422 |
test_contact_missing_* |
Missing required fields → 422 |
test_email_uses_real_credentials |
Non-placeholder creds → login() called |
test_email_demo_mode_skips_login |
Placeholder creds → login() not called |
test_email_auth_error_does_not_fail_request |
SMTPAuthenticationError → still success: true |
test_both_notification_and_confirmation_sent |
smtplib.SMTP constructed twice |
test_projects_returns_list |
GET /api/projects → list of 6 cards |
test_project_detail_valid |
GET /api/projects/genai-framework → correct detail |
test_project_detail_not_found |
Unknown slug → 404 |
test_experience_returns_list |
GET /api/experience → list of 13 entries |
test_experience_entry_valid |
GET /api/experience/0 → Witteveen+Bos entry |
test_experience_entry_not_found |
Out-of-range index → 404 |
test_skills_returns_list |
GET /api/skills → list of 5 categories |
test_contact_rate_limit |
6th POST /api/contact within a minute → 429 |
tests/test_cv_download.py — tests the /api/download-cv endpoint by patching CV_FILES to use a temporary PDF on disk.
| Test | What it asserts |
|---|---|
test_download_cv_english |
GET /api/download-cv → 200, application/pdf, correct filename |
test_download_cv_nederlands |
GET /api/download-cv?lang=nederlands → Dutch CV PDF |
test_download_cv_defaults_to_english_for_unknown_lang |
Unknown lang param falls back to English |
test_download_cv_returns_404_when_file_missing |
Missing file on disk → 404, CV file not found |
tests/test_cv_generation.py — tests the feature-flagged CV generation endpoints.
| Test | What it asserts |
|---|---|
test_cv_status_disabled |
GET /api/cv/status → {"enabled": false} when flag off |
test_cv_status_enabled |
GET /api/cv/status → {"enabled": true} when flag on |
test_cv_generate_disabled |
GET /api/cv/generate → 404 when flag off |
tests/test_cv_files_exist.py — validates that CV PDF files are present in the container's public/ directory.
| Test | What it asserts |
|---|---|
test_english_cv_file_exists |
public/CV_Sven_Relijveld_English.pdf is present |
test_nederlands_cv_file_exists |
public/CV_Sven_Relijveld_Nederlands.pdf is present |
Running locally:
CI: test.yml runs on PR (jobs: api, unit, typecheck, links); test-backend job in deploy-pr-preview.yml runs after each preview deploy.
See Also¶
Page history
| Field | Value |
|---|---|
| Last updated | 2026-05-05 |
Changelog
| Date | PRs | Summary |
|---|---|---|
| 2026-03-03 | #55 | Add frontmatter; fix SMTPAuthenticationError handling to show success:true (always returns success); add Testing section Incorrect error handling example that returned success:false for SMTPAuthenticationError |
| 2026-03-04 | #56 | Add GET /api/download-cv endpoint; update Docker Build snippet to include PDF COPY step |
| 2026-03-20 | #85 | Add projects, experience, skills endpoints and rate limiting; add slowapi to tech stack; expand testing table to 25 tests Inaccurate 'Caching: Response caching where applicable' from Performance section |
| 2026-03-22 | #89 | Add GET /api/health as the primary health route (proxied through nginx); GET /health retained for infra probes but excluded from OpenAPI schema; add dev server to OpenAPI servers block |
| 2026-04-12 | #181 | Add PostgreSQL database layer: SQLAlchemy async ORM, Alembic migrations, asyncpg driver; update endpoints to reflect DB-backed data; add Database section Inaccurate statement that projects/experience/skills data is hard-coded in main.py |
| 2026-04-13 | #183 | Fix Docker CMD snippet to include alembic migration step and non-root user; add test_cv_download.py and conftest.py to Testing section; update pytest run command |
| 2026-04-17 | Add Admin API section (POST /api/admin/*); add CV Generation section (feature-flagged endpoints); add test_cv_generation.py and test_cv_files_exist.py to Testing table; update CI reference from test-api.yml to test.yml | |
| 2026-04-21 | #206, #207, #247, #243, #253, #254 | Add UIGen token endpoint, feature-flags endpoint, Feature Flags section (Azure App Config); update CV Generation to reflect App Config backing; update Database connection section with MANAGED_IDENTITY_CLIENT_ID and Alembic token fix; update project structure for api/routers/ and api/feature_flags.py Inaccurate statement that CV_GENERATION_ENABLED is a direct env var (now App Config feature flag) |
| 2026-04-26 | #265, #267, #269, #270, #273, #274, #277, #299, #300, #304 | Update Database connection section: replace IMDS urllib with azure-identity ManagedIdentityCredential; fix Postgres username to MI principal name (not UUID); update Docker CMD to show Alembic retry; add API annotations note to UIGen Token section; add feature-flags retry note; update Technology Stack zod version Inaccurate description of IMDS token fetch using raw urllib (now uses azure-identity) |
| 2026-05-03 | #358, #362 | Remove /admin/ prefix from all admin write endpoints (now /api/projects, /api/experience, /api/skills/ — bearer token still enforced); add awards and certifications read endpoints; add portfolioContentApi to feature-flags; add PATCH /api/feature-flags admin endpoint; update DB models table to include Award, Certification, Skill; update project structure; update CV Generation method from GET to PATCH _Stale /api/admin/ prefix on admin endpoints; GET /api/cv/generate replaced by PATCH /api/cv/generate_ |
| 2026-05-04 | #370 | Update Project Structure section to reflect Phase 1 refactor: api/core/ (config, openapi, exception/logging placeholders), api/templates/ (Jinja2 email templates), api/repositories/, api/services/, api/data/ skeleton packages Flat project structure listing that did not show core/, templates/, repositories/, services/, data/ packages |
| 2026-05-05 | #375, #377, #379, #380, #381 | Full Arjan refactor Phases 2–6: Pydantic v2 response schemas (Phase 2); per-domain routers, domain exception hierarchy, shared Limiter (Phase 3); repository + service layers, EmailService, async CV gen, get_optional_db (Phase 4); seed.json single source of truth, ProjectPhase child table, migration 0005 (Phase 5); loguru structured logging, RequestIdMiddleware, DB-backed UIGen token store with SHA-256 hashing, migration 0006 (Phase 6) Stale Phase 1 project structure showing skeleton placeholders; static models table missing ProjectPhase and UigenToken |