"""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