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.