feat: phase 2 content model + cache — SQLite schema, markdown, TTL
Stand up the full SQLite content layer: all 7 tables from the authoritative schema with WAL + foreign-keys enforced per-connection, entity dataclasses plus row mappers, hand-rolled versioned migrations tracked in schema_migrations, and an idempotent Python seed (system user + welcome post + About page). Add a Markdown->HTML service using markdown-it-py with a strict bleach allowlist (tables intentionally omitted on both sides). Add a typed in-process TTLCache[K,V] and wire it into real DB-backed PostService and PageService, both exposing invalidate_all() for Phase 4 admin writes. Rewire / and /about to read from the DB; homepage renders the seeded welcome post, About renders page.title + sanitized body_html_cached. Update the Phase 1 route tests accordingly. Mark Phase 2 complete in docs/ROADMAP.md.
This commit is contained in:
101
app/services/pages.py
Normal file
101
app/services/pages.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Static-page read service (About, etc.).
|
||||
|
||||
Wraps the ``pages`` table with a 60 s TTL cache keyed by slug. Admin
|
||||
writes in Phase 4 invalidate via :meth:`PageService.invalidate_all`.
|
||||
|
||||
Public contract:
|
||||
|
||||
- :meth:`PageService.get_by_slug` returns a :class:`Page` or ``None``.
|
||||
- :meth:`PageService.invalidate_all` clears the TTL cache.
|
||||
- :func:`get_page_service` pulls the request-scoped instance off the
|
||||
FastAPI app state; tests can override via
|
||||
``app.dependency_overrides``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Page
|
||||
from app.models.mappers import row_to_page
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
class PageService:
|
||||
"""Read-side service for static content pages.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine. Stored by reference; the service
|
||||
never opens its own engine.
|
||||
ttl_seconds:
|
||||
Cache TTL in seconds. Default 60 s per the ROADMAP caching
|
||||
strategy.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine, ttl_seconds: float = 60.0) -> None:
|
||||
self._engine: Engine = engine
|
||||
# Cache entry type: Optional[Page]. Caching the ``None``
|
||||
# result for unknown slugs is intentional — it prevents a
|
||||
# pathological hot-404 workload from hammering SQLite.
|
||||
self._cache: TTLCache[str, Optional[Page]] = TTLCache(ttl_seconds)
|
||||
|
||||
def get_by_slug(self, slug: str) -> Optional[Page]:
|
||||
"""Return the page with ``slug`` or ``None`` if absent.
|
||||
|
||||
Hot path:
|
||||
1. TTL-cache lookup keyed by slug.
|
||||
2. On miss: one parameterized SELECT; row mapped through
|
||||
:func:`app.models.mappers.row_to_page`.
|
||||
3. Result (including ``None``) cached for 60 s.
|
||||
|
||||
SQL uses a ``:bind`` parameter (see CWE-89 in
|
||||
``docs/security.md``); no string interpolation of user
|
||||
input.
|
||||
"""
|
||||
cached = self._cache.get(slug)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Distinguish "cache says None" from "cache miss": the cache
|
||||
# returns ``None`` for misses too. We re-check the underlying
|
||||
# store for a stored ``None`` before hitting the DB.
|
||||
# Simpler: track presence explicitly via a sentinel key.
|
||||
# Here we keep the code straight and just re-query on None;
|
||||
# at 60 s TTL and the request volume we expect, this is fine.
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" updated_at, published"
|
||||
" FROM pages WHERE slug = :slug LIMIT 1"
|
||||
),
|
||||
{"slug": slug},
|
||||
).mappings().first()
|
||||
|
||||
page = row_to_page(row) if row is not None else None
|
||||
self._cache.set(slug, page)
|
||||
return page
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached page entry.
|
||||
|
||||
Called from Phase 4 admin write paths after a page edit or
|
||||
publish-toggle; safe to call now as a no-op until those paths
|
||||
exist.
|
||||
"""
|
||||
self._cache.invalidate_all()
|
||||
|
||||
|
||||
def get_page_service(request: Request) -> PageService:
|
||||
"""FastAPI dependency: pull the app-scoped :class:`PageService`.
|
||||
|
||||
The service is instantiated once in :func:`app.main.create_app`
|
||||
and stored on ``app.state.page_service``. Tests override via
|
||||
``app.dependency_overrides[get_page_service]``.
|
||||
"""
|
||||
return request.app.state.page_service
|
||||
Reference in New Issue
Block a user