English · Español
Lab 00 — Arranque del portal vacío¶
🇪🇸 Antes de cualquier funcionalidad, un esqueleto que arranca, sirve
/healthzy devuelveOK. Si esto no es verde, nada más importa. Phase 41 es una app web sobre cimientos ya construidos (Phase 33 = HTTP, Phase 37 = sanitizer). Aquí solo enchufamos el armazón.
Objetivo¶
Levantar una app FastAPI miniportal vacía que arranque en < 3 s en el i5-8250U de Borja, sirva un /healthz JSON, renderice una plantilla Jinja base en /, y entregue assets estáticos correctamente. Levantarla con just portal-dev. El test de integración tests/portal/test_healthz.py debe estar verde antes de que se abra cualquier lab de funcionalidad.
Por qué existe este lab¶
Cada lab posterior de la Fase 41 asume una app corriendo. Si el scaffold está mal el día uno, cada test posterior está depurando una base rota en vez de la funcionalidad bajo estudio. Este lab es pequeño a propósito — controla el resto de la fase aislando "¿la app siquiera arranca?" de cualquier lógica de negocio real.
También fuerza la decisión del app factory pronto. Un singleton a nivel de módulo (app = FastAPI()) hace que testear sea doloroso en el lab 04+ cuando las fixtures necesitan su propia app configurada. El patrón factory (make_app(settings)) es la única forma correcta; el lab 00 lo deja clavado antes de que la tentación del atajo se instale.
Prerrequisitos¶
src/miniportal/BLUEPRINT.mdescrito y aprobado (véase plan Fase 41 §3).- La Fase 33 ya provee patrones de
src/miniserve/(middleware de request id, logging estructurado) — reutilizados aquí, no re-derivados. - El módulo sanitizer de la Fase 37 existe en
src/sanitizer/(importado de forma perezosa en labs posteriores; no requerido para el lab 00).
Entregables¶
src/miniportal/__init__.py— exportamake_app.src/miniportal/app.py— factorymake_app(settings).src/miniportal/settings.py—PortalSettingsdepydantic-settings(controlado por env, sin secretos en código).src/miniportal/routes/health.py— ruta/healthzque devuelve{"status": "ok", "version": ...}.src/miniportal/routes/home.py— ruta/que renderizabase.html.jinjacon un cuerpo "hello" de una línea.src/miniportal/templates/base.html.jinja— plantilla base con{% block content %}{% endblock %}.src/miniportal/static/portal.css— placeholder vacío; demuestra que el montaje de estáticos funciona.- Receta del
Justfileportal-devejecutandouv run uvicorn miniportal.app:make_app --factory --reload. tests/portal/__init__.py,tests/portal/conftest.py,tests/portal/test_healthz.py.src/miniportal/README.md— resumen del módulo de un párrafo que apunta al BLUEPRINT.
Paso 1 — uv add de las dependencias¶
uv add fastapi uvicorn jinja2 argon2-cffi itsdangerous python-multipart sqlmodel alembic
uv add --group dev httpx pytest-asyncio
argon2-cffi e itsdangerous se traen aquí aunque el lab 00 no los use — fijar sus versiones en uv.lock ahora evita un bisect de version-skew tres labs más adelante. Actualiza la tabla [project] de pyproject.toml en consecuencia.
Mensaje de commit: chore(portal): pin web stack for Phase 41 (Conventional Commits per CLAUDE.md §A4).
Paso 2 — El app factory¶
# src/miniportal/app.py
from __future__ import annotations
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from miniportal.settings import PortalSettings
from miniportal.routes import health, home
def make_app(settings: PortalSettings | None = None) -> FastAPI:
"""Build the miniportal ASGI app.
Pure function: same settings → same app. No globals, no side effects on import.
"""
raise NotImplementedError("Lab 00 step 2 — wire settings, mounts, routers.")
Restricciones sobre el cuerpo que Borja escribe:
- Sin
app = ...a nivel de módulo. El factory es el único punto de construcción. - Archivos estáticos montados en
/static, servidos desdesrc/miniportal/static/. - Plantillas configuradas con
Jinja2Templates(directory=...), expuestas víaapp.state.templatespara los handlers de ruta. - Ningún middleware entra directamente al paquete
miniservede la Fase 33 — copia la firma del middleware de request-id, re-importa el helper si es reutilizable, pero no acoples los dos servicios.
Paso 3 — Settings¶
# src/miniportal/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class PortalSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="LYNX_PORTAL_", env_file=".env.portal")
database_url: str = "sqlite:///./var/portal.db"
session_secret: str = "dev-only-do-not-use-in-prod" # noqa: S105 — replaced at deploy
static_dir: str = "src/miniportal/static"
templates_dir: str = "src/miniportal/templates"
def __post_init__(self) -> None:
raise NotImplementedError("Lab 00 step 3 — validate that session_secret is set when not in dev mode.")
El validador __post_init__ debe rechazar el session_secret por defecto cuando LYNX_PORTAL_ENV != "dev". Esta es una lección de la Fase 37 re-aplicada: los secretos nunca tienen un valor conocido por defecto en producción.
Paso 4 — /healthz¶
# src/miniportal/routes/health.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/healthz")
async def healthz() -> dict[str, str]:
"""Return liveness. Independent of DB so a broken DB does not hide app-up signal."""
raise NotImplementedError("Lab 00 step 4 — return status, version (from `importlib.metadata`), timestamp.")
La forma de la respuesta está fijada por el lab 00 — labs posteriores asumen {"status": "ok", "version": "<x.y.z>", "ts": "<iso8601>"}. Si la cambias aquí, cada monitor aguas abajo se rompe.
Paso 5 — Plantilla base + ruta home¶
src/miniportal/templates/base.html.jinja:
<!doctype html>
<html lang="{{ locale|default('en') }}">
<head>
<meta charset="utf-8" />
<title>{% block title %}lynx-cortex portal{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/portal.css') }}" />
</head>
<body>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
La ruta home renderiza la plantilla con una sola línea visible para que el smoke test tenga algo que afirmar.
Paso 6 — just portal-dev¶
El puerto 8001 se elige para que nunca colisione con el miniserve de la Fase 33 en :8080. Documenta la elección de puerto en src/miniportal/README.md.
Paso 7 — El smoke test¶
# tests/portal/test_healthz.py
from fastapi.testclient import TestClient
from miniportal.app import make_app
from miniportal.settings import PortalSettings
def test_healthz_returns_ok():
raise NotImplementedError("Lab 00 step 7 — build app via factory, hit /healthz, assert 200 + shape.")
def test_static_assets_served():
raise NotImplementedError("Lab 00 step 7 — GET /static/portal.css returns 200.")
def test_home_renders_base_template():
raise NotImplementedError("Lab 00 step 7 — GET / returns 200 and contains <html lang=...>.")
Cómo es "done"¶
-
uv sync --frozentiene éxito; lockfile commiteado. -
just portal-devlevanta el servidor en < 3 s en la máquina de Borja; loggeado enexperiments/41-bootstrap/timing.txt. -
curl :8001/healthzdevuelve{"status": "ok", ...}. -
curl :8001/devuelve HTML que contiene la plantilla base. -
curl :8001/static/portal.cssdevuelve 200. -
pytest tests/portal/test_healthz.pyestá verde (los tres tests pasan). -
mypy --strict src/miniportal/está verde. -
bandit -r src/miniportal/reporta cero hallazgos altos. -
src/miniportal/README.mdexiste y enlaza al BLUEPRINT.
Trampas comunes¶
app = FastAPI()a nivel de módulo. Parece más corto. Rompe cada fixture de test dos labs más adelante cuando cada test quiere sus propias settings. Solo factory.- Importar el engine de la DB en el top del módulo. SQLModel tira de SQLAlchemy que es pesado; si construyes el engine ansiosamente al importar, el smoke test arranca lento y
/healthzdepende de la disponibilidad de la DB. Constrúyelo perezosamente dentro del factory. - Hardcodear el puerto. La Fase 41 puede correr junto a
miniserveen el mismodocker compose. Lee el puerto de las settings. - Saltarse el test de archivos estáticos. Cuando las plantillas Jinja cargan CSS vía
url_for('static', ...), un montaje roto produce silenciosamente 404s que el navegador ignora. El test lo atrapa ahora, no tras el trabajo de avatares del lab 02. - Tratar este lab como "trivial" y no escribir el BLUEPRINT primero. El BLUEPRINT es el único lugar donde vive "por qué un factory"; sin él, el siguiente mantenedor (o el Borja del futuro) re-deriva la decisión desde cero.
Siguiente: lab/01-passwordless-onboarding.md — el flujo de primer login sin contraseña por defecto.