Skip to content

Handoff: API Refactor Phase 2 — Pydantic Response Schemas

Branch: refactor/api-arjan-phase2 Status: Complete — 188/188 tests passing, flake8 clean

What Was Done

New file: api/schemas/responses.py

Created Pydantic v2 response schemas with from_attributes=True and populate_by_name=True. All schemas inherit from BaseResponse.

Schemas created:

  • HealthResponse{status, message}
  • ContactResponse{success, message}
  • FeatureFlagsResponse{cvGeneration, portfolioContentApi}
  • ProjectPhase{name, description}
  • ProjectCard — all project card fields with longDescription (validation_alias: long_description)
  • ProjectDetail — full project detail with timeline decoding and prevId/nextId support
  • WorkEntry — work history entry with additionalProjects (validation_alias: additional_projects)
  • Skill{name, projects}
  • SkillCategory — with iconName/iconColor (validation_aliases: icon_name, icon_color)
  • Award — with iconName/bgColor (validation_aliases: icon_name, bg_color)
  • Certification — with iconType/linkText (validation_aliases: icon_type, link_text)

Updated: api/schemas/__init__.py

Added explicit re-exports of all response schemas.

Updated: api/main.py

Imports changed:

  • ORM models renamed at import time to avoid name collision with response schemas:
  • Award as AwardORM, Certification as CertificationORM, SkillCategory as SkillCategoryORM, WorkEntry as WorkEntryORM
  • Added from api.schemas.responses import ... for all response schema classes

Response model classes removed (replaced by api/schemas/responses.py):

  • ContactResponse, HealthResponse, ProjectCard, ProjectPhase, ProjectDetail
  • WorkEntryModel, SkillModel, SkillCategoryModel, CertificationModel, AwardModel
  • FeatureFlagsResponse

Converter helpers deleted:

  • _project_to_card — replaced by ProjectCard.model_validate(p)
  • _decode_timeline — logic moved into ProjectDetail._decode_timeline field_validator
  • _project_to_detail — replaced by _project_detail_from_orm(p, prev_id, next_id)
  • _work_entry_to_model — replaced by WorkEntry.model_validate(w)
  • _skill_cat_to_model — replaced by SkillCategory.model_validate(c)
  • _award_to_model — replaced by Award.model_validate(a)
  • _cert_to_model — replaced by Certification.model_validate(c)

Key Design Decisions

validation_alias vs alias

Used Field(validation_alias="snake_case") rather than Field(alias="snake_case").

Why: FastAPI serializes responses with model_dump(by_alias=True). When using Field(alias=...), the alias becomes the JSON output key. With Field(validation_alias=...), the alias is only used for reading (ORM attribute lookup), and the field name (camelCase) is used for JSON output — which is what the frontend expects.

UUID coercion in BaseResponse

A model_validator(mode="before") in BaseResponse detects when the input is an ORM object with a UUID id field, converts it to a plain dict (via __dict__), and stringifies the UUID. This avoids the need for str(p.id) at every call site.

The dict-based approach is used because from_attributes mode with mode="before" validators expects either a dict or the original object — returning a dict from a mode="before" validator is the correct Pydantic v2 pattern.

ProjectDetail timeline handling

The ORM stores timeline phases in timeline_encoded: list[str] as "name|||description" strings. The ProjectDetail._decode_timeline field_validator decodes these to list[ProjectPhase].

Since prevId/nextId are not ORM columns, the call site uses a plain dict:

python
ProjectDetail.model_validate({
    "id": str(p.id),
    "timeline": p.timeline_encoded,  # decoded by field_validator
    "prevId": prev_id,
    "nextId": next_id,
    ... other fields ...
})

This is encapsulated in the thin helper _project_detail_from_orm(p, prev_id, next_id) which remains in main.py. It is not a "converter" in the Phase 1 sense — it only exists because two computed fields (prevId, nextId) cannot come from the ORM object alone.

Static data unchanged

The large static data dictionaries (PROJECT_CARDS, WORK_HISTORY, etc.) continue to use keyword-argument construction (e.g. SkillCategory(iconName="Cloud", ...)). This works because populate_by_name=True allows using the field name (iconName) in addition to the validation_alias (icon_name).

What Remains (if not done)

Phase 2 is complete. Potential future improvements:

  • Phase 3: Move static data lists out of main.py into dedicated seed modules or a separate api/data/ package.
  • Phase 4: Extract endpoint handlers into the router files under api/routers/.
  • Phase 5: Move CV generation logic into api/services/cv.py.