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.
102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
"""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
|