Skip to content

Architecture

cms-api is a single FastAPI application assembled by an app factory in src/cms_api/main.py. The module builds one FastAPI instance, registers the shared error handlers, mounts each block's router under its prefix, and wires the rate-limit middleware and CORS.

App factory and mount map

main.py mounts five routers. Each is an APIRouter aggregated in its own package and attached with app.include_router(..., prefix=...):

Router Mount prefix Gate Source
health_app /api none cms_api.health.routes
auth_app /admin/v1 none (issues tokens) cms_api.authentication.routes
admin_app /admin/v1 Depends(validate_admin) for the whole block cms_api.admin.routes
public_app /public/v1 none (open read) cms_api.public.routes
public_v2_app /public/v2 Depends(validate_worker_api_key) for the whole block cms_api.public.routes.v2

The two routers that share the /admin/v1 prefix are deliberate: the auth routes (login, refresh, logout) live outside the admin gate because a caller cannot present a valid admin token before it has logged in. Every other admin route is gated -- validate_admin is attached as a block-level dependency on admin_app, so it runs for the entire block rather than per route.

The v2 block is gated the same way, with validate_worker_api_key attached at mount time, so all six v2 endpoints require the worker bearer key.

flowchart LR
    subgraph Admin[Admin consumers]
      A1[cms-admin UI]
      A2[Editorial tools]
      A3[Publishing workflows]
    end

    subgraph Workers[Worker / public consumers]
      W1[cms-cf-worker]
      P1[comparison-app]
      P2[public clients]
    end

    APP[FastAPI app: main.py]
    AUTH[validate_admin: JWT + Redis]
    WKEY[validate_worker_api_key: bearer]

    A1 --> AUTH
    A2 --> AUTH
    A3 --> AUTH
    AUTH -->|/admin/v1| APP

    W1 --> WKEY
    WKEY -->|/public/v2| APP
    P1 -->|/public/v1| APP
    P2 -->|/public/v1| APP

    APP -->|/api/status| HEALTH[Health]
    APP --> DB[(hq.sql_models / cms_db)]
    APP --> REDIS[(Redis sessions)]

The login route (POST /admin/v1/auth/login) and the health route (GET /api/status) are the only entry points reachable with no credential.

Sync, not async

The application is written with synchronous store access. The public stores use a synchronous session_scope() and SQLAlchemy session.query(...), and the v2 store methods are plain def (not async def), mirroring v1. The route handlers that call them are likewise synchronous functions; FastAPI runs them in a threadpool. This is a recorded deviation from the AISD-115 design, which described an asyncio.gather / async-SQLAlchemy approach that does not apply to the existing synchronous store. See Public v2 API.

Package layout

The package lives under src/cms_api/:

Package Responsibility
main.py App factory; mounts every block, adds CORS + rate-limit middleware.
config/ cfg.py -- ENVIRONMENT-driven runtime config (secrets via fs_config).
authentication/ JWT + Redis session auth (utils.py), worker API-key gate (worker.py), login/refresh/logout routes, the UserRepository seam, models, permissions, exceptions.
datastore/ DB connector (db.py) and Redis client (redis_client.py).
admin/ Block A routers (routes/), schemas, the admin store (store/), deps.py (session_scope), constants.
public/ Block B: v1 routes (routes/), v2 routes (routes/v2/), schemas (schemas/), the public store (store/), cache.py, media.py, bindings.py, deps.py.
health/ GET /api/status -- always {"status": "OK"}; adds {"db": ...} when CHECK_DB=1.
middlewares/ limitation.py -- the per-client fixed-window rate limiter.
common/ errors.py (the APIError envelope + handlers), pagination.py, EAV settings helpers.

Configuration model

config/cfg.py resolves all runtime settings at import time, selecting the secret source from the ENVIRONMENT variable:

ENVIRONMENT Secret source AWS access
prd /prd/... yes
stg /stg/... yes
local /stg/... (developer convenience) no (offline-tolerant)
test os.environ + deterministic non-prod defaults never

An unset ENVIRONMENT defaults to prd -- an instance with no explicit env var behaves like production rather than silently falling through to staging.

Secrets are loaded through fs_config (get_secret / get_parameter) and are never hardcoded. The resolved module-level values are SECRET_KEY (JWT signing), REDIS_SETTINGS, CMS_DB_SETTINGS, PREVIEW_SECRET, and WORKER_API_KEY. In test/local (offline) mode a missing secret falls back to a deterministic non-prod value or an os.environ override, so CI and pytest run with no AWS access; in prd/stg a missing critical secret is a hard error (the service refuses to boot on a guess).

The hq consumption seam

The CMS DB settings are resolved from hq.cms.db.config.load_mysql_settings, imported under a guarded try/except. When hq is not importable (offline / CI) the value is None and the DB layer treats that as "no DB configured", reporting it through the health check rather than crashing. hq is consumed off the PYTHONPATH; packaging/pinning it into the deploy image is a documented follow-up.

See the autodoc in API reference / Application for the main.py symbols, and the in-repo docs/api-architecture.md for the AISD-90 design rationale.