Public v2 API¶
The v2 surface (/public/v2) is the worker-optimized read API consumed by the
cms-cf-worker (Cloudflare Worker). It was designed in AISD-115 and built in
AISD-116. It is mounted alongside v1 (/public/v1), which stays in place during
rollout and is retired once v2 is live in production.
- Routes:
src/cms_api/public/routes/v2/-- one file per endpoint, aggregated ontopublic_v2_appinroutes/v2/__init__.pyand mounted inmain.pybehindvalidate_worker_api_key. - Schemas:
src/cms_api/public/schemas/v2.py-- every response model setsextra="forbid", so any drift between the store output and the declared schema fails fast in tests rather than silently shipping an unexpected field. - Auth: the whole block requires the worker API key (see Auth flow).
Endpoints¶
Every DB-backed endpoint emits ETag, Last-Modified, and Cache-Control, and
honors If-None-Match / If-Modified-Since with a 304 (see
Request lifecycle).
| # | Method + path | Purpose | s-maxage / swr |
|---|---|---|---|
| 1 | GET /public/v2/ping |
Liveness; no DB, no caching. Returns {"status": "ok", "time": <iso>}. |
-- |
| 2 | GET /public/v2/sites/{site}/settings |
Site identity + merged settings + light categories/tags + resolved templates + active_template + ads_txt/robots_txt. |
300 / 86400 |
| 3 | GET /public/v2/sites/{site_id}/pages/{slug} |
One article + flat SEO + author + breadcrumbs + related. ?version=N reads content_history (preview-gated). |
300 / 600 |
| 4 | GET /public/v2/sites/{site_id}/authors/{slug} |
Author profile + computed SEO + article stats. | 300 / 600 |
| 5 | GET /public/v2/sites/{site_id}/lists?kind=... |
Metadata-only content cards, cursor-paginated. | 120 / 600 |
| 6 | GET /public/v2/sites/{site_id}/sitemap |
Flagged content URLs + static-page URLs, cursor-paginated. | 3600 / 3600 |
Parameters by endpoint¶
/settings
| Param | In | Notes |
|---|---|---|
site |
path | Site id, acronym, or domain (see the resolver). |
language |
query | Optional language filter. |
Returns a SettingsBundle: site (SiteIdentity),
site_settings (SiteSettingsBlob -- per-column merge of site over global
defaults, with light categories/tags, templates resolved by precedence
override > block > element default, and active_template), and meta.
/pages/{slug}
| Param | In | Notes |
|---|---|---|
site_id |
path | Resolver input. |
slug |
path | Article slug. |
language |
query | Optional; a slug published in another language is skipped, not 404'd. |
version |
query, >=1 |
Reads a content_history snapshot; preview-gated. |
related_limit |
query, 0..50 |
Related-article count (default 6). |
X-CMS-Preview |
header | Shared secret required when version is set. |
Only content_type in ARTICLE_CONTENT_TYPES (currently ("homepage_article",))
is served as an article. A versioned read keeps the live row's identity columns
and overlays only the snapshot's mutable business columns (the
HISTORY_BUSINESS_FIELDS merge policy in v2_common). Returns an
ArticlePage.
/authors/{slug}
| Param | In | Notes |
|---|---|---|
site_id |
path | Resolver input. |
slug |
path | Author slug (scoped to the site, active only). |
Returns an AuthorProfile: the author slice, a
computed Person SEO block, and stats (article_count,
latest_published_at).
/lists
| Param | In | Notes |
|---|---|---|
site_id |
path | Resolver input. |
kind |
query, required | One of homepage, category, author, featured, trending, related, recent, search. |
category |
query | Required when kind=category. |
author_slug |
query | Required when kind=author. |
related_to |
query | Required when kind=related. |
q |
query | Required when kind=search (LIKE substring on title + description). |
language |
query | Optional language filter. |
limit |
query, 1..100 |
Page size (default 20). |
cursor |
query | Opaque cursor; response carries next_cursor. |
A missing required parameter for the chosen kind, or an unknown kind, raises
a 400 validation_error. Returns a ListResponse of
metadata-only ListItem cards.
/sitemap
| Param | In | Notes |
|---|---|---|
site_id |
path | Resolver input. |
since |
query | RFC1123 or ISO date; only content published at/after it. |
limit |
query, 1..50000 |
Page size (default 5000). |
cursor |
query | Opaque cursor. Static-page entries are emitted only on the first page (cursor unset). |
Includes content flagged is_featured OR show_in_homepage, plus static-page URLs
derived from site_settings.navigation whose path maps to a type='page'
template element (the homepage / is always included). Returns a
SitemapResponse.
The site resolver¶
All site-keyed v2 endpoints resolve the {site} / {site_id} path parameter
through resolve_site_any: it tries int(site) against sites.id first, then
falls back to the v1 acronym OR domain predicate. An inactive or unknown site
yields a 404 resource_not_found.
Schema convention¶
Response shapes are declared in cms_api.public.schemas.v2 with
model_config = ConfigDict(extra="forbid") on every model. Because the store
populates these models directly, an extra or renamed field surfaces as a
validation error in tests rather than silently shipping. See
API reference / Public v2 schemas.
Recorded deviations¶
These were recorded against the AISD-115 design at implementation time:
- Sync, not async. The design's
asyncio.gather/ async-SQLAlchemy language does not apply: the store is synchronous (session_scope()+session.query), so the v2 methods are synchronousdef, mirroring v1. feature_flagsis an empty object. The currentsite_settingsschema has nofeature_flagscolumn; the field is returned as{}until one exists.meta.etagin the body isnull. The authoritative ETag is the HTTPETagheader; computing it into the body would be self-referential.searchis aLIKEsubstring match, not MySQLMATCH ... AGAINST-- no fulltext index is declared inhq.sql_models.