Store design (SQL vs memory)¶
The public block reads through a store abstraction with two interchangeable implementations and one shared shaping layer. The route handlers never touch SQLAlchemy directly; they call store methods that return ready-built response models.
The dependency seam¶
cms_api.public.store.get_public_store is the single dependency that route
handlers inject with Depends(get_public_store):
PublicStore = Union[MemoryPublicStore, "SqlPublicStore"]
def get_public_store() -> PublicStore:
"""Return the production SQL-backed public store (override in tests)."""
from .sql import SqlPublicStore
return SqlPublicStore()
Two details matter:
PublicStoreis a union type, so handler signatures and shared helpers can accept either implementation.SqlPublicStoreis imported lazily inside the function, not at module top. This keepsimport cms_api.public.storefrom pulling in SQLAlchemy (and the admin store /session_scope), so a pure in-memory test run never imports the SQL stack.
Why two stores¶
SqlPublicStore |
MemoryPublicStore |
|
|---|---|---|
| Module | cms_api.public.store.sql |
cms_api.public.store.memory |
| Backing | session_scope() + SQLAlchemy queries over hq.sql_models |
plain Python dicts (sites, content, authors, settings, ...) |
| Used in | production | unit tests |
| Imports SQLAlchemy | yes | no |
Tests override the dependency with the memory store via
app.dependency_overrides[get_public_store]. The memory store fills its dicts
with the same admin schema models (SiteOut, ContentOut, AuthorOut, ...)
that the SQL store reads from the DB, so a test can build a fixture in memory and
exercise the exact route handlers used in production -- no database required.
The shared shapers: v2_common¶
The reason memory-backed tests can validate the SQL store's contract is that
both stores fetch raw rows their own way, then call the same pure shaper
functions in cms_api.public.store.v2_common to build the response envelopes.
Keeping the shaping logic in one place guarantees the two stores return
byte-identical shapes.
flowchart TD
R[v2 route handler] --> G{get_public_store}
G -->|prod| SQL[SqlPublicStore]
G -->|tests override| MEM[MemoryPublicStore]
SQL -->|raw ORM rows| VC[v2_common shapers]
MEM -->|raw dict rows| VC
VC --> M[Pydantic v2 models<br/>extra=forbid]
M --> R
v2_common owns, among others:
- Single sources of truth --
ARTICLE_CONTENT_TYPES(whichcontent_types the/pagesendpoint serves as articles),CACHE_HINTS(per-endpoints-maxage/swr),SETTINGS_FIELDS(the mergedsite_settingscolumns), and the versioned-read merge policy (HISTORY_IDENTITY_FIELDSvsHISTORY_BUSINESS_FIELDS). - Envelope builders --
build_settings_blob,build_article_content,build_article_seo,build_author_seo,build_breadcrumbs,list_item_from_fields,author_slice,author_stats,related_item,sitemap_entry,meta_envelope/cache_hint. - Pure helpers --
parse_since,compose_loc,navigation_urls,static_sitemap_entries,featured_image_variants.
Because these helpers are data-access agnostic (they take row-like objects and
return models), the same call from SqlPublicStore and MemoryPublicStore
produces the same SettingsBundle, ArticlePage, AuthorProfile,
ListResponse, or SitemapResponse. The extra="forbid" config on those models
then catches any field-level drift in either store.
Media URL absolutization¶
Both stores route media columns (featured_image, og_image, author image,
the settings favicon/og-image/icon) through absolutize_media_url
(cms_api.public.media), which prefixes relative paths with the site's
cdn_base_url and leaves already-absolute (http(s)://, //) URLs untouched.
Centralizing this in the shaper keeps the two stores consistent here too.
See the autodoc in API reference / Public store for the full method and helper signatures.