English · Español
Teoría 04 — Piezas del portal (adelanto a la Fase 41)¶
La Fase 33 enseña FastAPI no como un fin, sino como cuatro primitivas reutilizables: lifespan para abrir DB y vault,
Depends()para inyectar la sesión por petición, el orden del middleware ASGI con CSRF después de la sesión, y HTMX para no caer en una SPA. Estas son las piezas que la Fase 41 ensambla en un portal multi-estudiante.
Por qué existe este capítulo¶
Los tres primeros capítulos de teoría de la Fase 33 se mantienen cerca de una sola preocupación — poner el tutor de gramática de la Fase 32 detrás de un endpoint HTTP que escale bajo carga concurrente. Ese es el problema que marca el ritmo y la mayor parte del tiempo de Borja va ahí.
Este capítulo existe para un público distinto: el Learner Portal de la Fase 41 reutilizará los patrones de FastAPI de la Fase 33 para entregar el currículo de gramática §A13 a múltiples estudiantes. La Fase 41 puede arrancar el build de su MVP antes de su slot (según LYNX_CORTEX_ADDENDUM.md §A14), pero no puede copiar patrones que la Fase 33 todavía no haya enseñado. Así que la Fase 33 le debe a la Fase 41 cuatro pequeñas lecciones — independientes del problema de batching — y este capítulo paga esa deuda. Léelo como una lista de vocabulario: cuando la Fase 41 diga "DB gestionada por lifespan", esa frase viene del §1 de abajo.
La disciplina de cross-links: cada sección referencia el capítulo de arquitectura de la Fase 41 (docs/phase-41-learner-portal/theory/01-architecture.md) y el capítulo de auth/vault (docs/phase-41-learner-portal/theory/03-auth-and-vault.md). En caso de duda, los docs del portal son el sitio canónico para ver los cuatro patrones compuestos; este capítulo los enseña uno a uno.
§1 — Recursos gestionados por lifespan (sqlite + vault)¶
FastAPI hereda el protocolo lifespan de Starlette: un async context manager que corre una vez al arrancar la app y otra al apagarla. Es el sitio correcto para abrir el pool de conexiones SQLite, descifrar el vault de contraseñas y guardar los handles en app.state. Hacer esto dentro de un handler de ruta está mal — cada request pagaría el coste de reapertura, y las requests concurrentes pelearían por los locks del archivo. Hacerlo en tiempo de import del módulo también está mal — los tests no pueden inyectar una DB fresca en memoria sin monkey-patching, y el side-effect de import rompe uvicorn --reload.
El cuerpo del lifespan corre en el event loop. Cualquier cosa que bloquee (una llamada síncrona a argon2.PasswordHasher().hash("calibrate") para hacer JIT del backend de Argon2, un sqlite3.connect con check_same_thread=False) está bien porque el arranque es one-shot; lo que importa es el contrato de que una vez que yield retorna, cada dependencia que la app necesita está viva. Al apagar, el mismo contexto las cierra en orden inverso — conexiones cerradas, clave del vault puesta a cero, locks de archivo liberados. Esta es la disciplina que src/miniportal/app.py seguirá cuando Borja lo implemente.
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.engine = create_engine(config.db_url)
init_schema(app.state.engine)
app.state.vault = Vault.open(config.vault_path, key=derive_key())
yield
app.state.vault.close()
app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)
Referencia hacia delante: el lifespan del portal abre tanto sqlite como el handle de minivault; ver docs/phase-41-learner-portal/theory/01-architecture.md §Nivel 2.
§2 — Cadenas de Depends() y objetos con ámbito de request¶
La inyección de dependencias de FastAPI tiene dos ámbitos que los principiantes confunden: ámbito de app (un objeto construido una vez y reutilizado — p. ej. el engine, el vault) y ámbito de request (un objeto construido por request — p. ej. una sesión de DB, el estudiante actual). La regla es mecánica: un atributo app.state.X es de ámbito app; un valor devuelto por una factory Depends(get_X) es de ámbito request (salvo que esa factory use lru_cache, que es un foot-gun para objetos con estado). Mezclarlos — coger un engine de DB de larga vida y tratar de hacer commit de una transacción entre requests — es el bug más común con FastAPI.
Las cadenas de dependencias componen: require_csrf depende de require_student, que depende de get_db_session, que depende de app.state.engine. FastAPI las resuelve una vez por request, en orden topológico, y pasa los valores resueltos al handler. La cadena también funciona como autorización: las rutas que vinculan current_user = Depends(require_admin) son inaccesibles para no admins, porque require_admin lanza 403 antes de que el cuerpo del handler corra. La Fase 41 se apoya mucho en esto — toda ruta protegida está a un Depends(require_student) de ser pública.
def get_db_session(request: Request) -> Iterator[Session]:
with Session(request.app.state.engine) as s:
yield s
def require_student(
request: Request,
db: Session = Depends(get_db_session),
) -> Student:
token = request.cookies.get("session")
if not token:
raise HTTPException(401)
return load_student_from_signed_token(token, db)
@app.get("/me")
def me(student: Student = Depends(require_student)):
return {"username": student.username}
Referencia hacia delante: el grafo completo de dependencias del portal está esbozado en docs/phase-41-learner-portal/theory/01-architecture.md §"Components inside miniportal".
§3 — Orden del middleware Starlette y la regla CSRF-después-de-sesión¶
El middleware ASGI es una pila: el primer middleware añadido es el envoltorio más externo de la request, y el más interno de la response en la vuelta. FastAPI hereda el add_middleware de Starlette, que añade al principio de la cadena, así que la última llamada a add_middleware acaba más externa. Esta inversión hace tropezar a todo el mundo la primera vez que la lee, así que la convención en miniserve y miniportal es llamar a add_middleware en orden más-externo-primero y tratar la secuencia de llamadas como el diagrama literal del pipeline.
La regla crítica para la Fase 41: la validación CSRF tiene que correr después de la decodificación de sesión, no antes. ¿Por qué? Los tokens CSRF se firman con la clave HMAC por-sesión, y el validador necesita saber qué sesión está verificando. Si CSRF corre primero, no tiene id de sesión y tiene que caer a un valor de cookie — que es exactamente en lo que CSRF se supone que no debe confiar. Secuencia: rate-limit → body-size → injection-filter → decodificar sesión → validador CSRF → validador de schema → handler. Invertir CSRF y decodificación de sesión convierte la defensa en un no-op; este es un hallazgo real de la Fase 37, no una preferencia estilística.
app.add_middleware(RateLimitMiddleware, per_min=30) # más externo
app.add_middleware(BodySizeLimitMiddleware, max_bytes=64_000)
app.add_middleware(InjectionFilterMiddleware, fields=["body_markdown"])
app.add_middleware(SessionMiddleware, secret_key=cfg.secret_key)
app.add_middleware(CSRFMiddleware, signer=cfg.csrf_signer) # más interno
Referencia hacia delante: el orden completo del middleware del portal, con justificación de la posición de cada capa, vive en docs/phase-41-learner-portal/theory/01-architecture.md §"The canonical submit exam answer sequence".
§4 — El trade-off HTMX vs SPA¶
El portal devuelve HTML, no JSON. Esto es una compensación deliberada contra el anti-goal del §10 de LYNX_CORTEX.md de frameworks de cliente pesados. Las dos arquitecturas viables para una app multi-página autenticada en 2026 son: (a) HTML renderizado en servidor con HTMX para updates parciales, o (b) una API JSON consumida por una SPA React/Vue/Svelte. La SPA gana en estado rico en cliente (modo offline, updates optimistas, cursores colaborativos en vivo); el camino HTMX gana en todos los demás ejes — tamaño de bundle, complejidad de build, accesibilidad por defecto, renderizado server-side gratis, sin toolchain.
El currículo del tutor de gramática no tiene estado rico en cliente. Un estudiante lee una página, escribe una nota, envía un quiz, ve un resultado. Cada interacción es una request-response. HTMX da swaps parciales del DOM vía atributos hx-get / hx-post, lo que significa "haz click en 'siguiente pregunta' y solo el div de la pregunta se re-renderiza" sin escribir una línea de JavaScript. La historia de accesibilidad también es estrictamente mejor: las páginas HTMX funcionan con JavaScript desactivado (los atributos hx-* degradan a posts <form> simples), algo que una SPA no puede igualar. La compensación es que HTMX no puede hacer nada mientras está offline — no hay almacén en el cliente. Para el portal, eso está bien: un estudiante sin conexión a la red no puede registrar progreso de todos modos.
<form hx-post="/phase/13/quiz" hx-target="#quiz-result">
<input name="csrf_token" type="hidden" value="{{ csrf_token }}">
<input name="answer" type="text">
<button>Submit</button>
</form>
<div id="quiz-result"></div>
Referencia hacia delante: los patrones HTMX del portal, incluyendo la convención de respuesta parcial de error, están en docs/phase-41-learner-portal/theory/01-architecture.md §"The canonical submit exam answer sequence" y el walkthrough del flujo de auth en docs/phase-41-learner-portal/theory/03-auth-and-vault.md §"No-password-by-default".
Referencia hacia delante a la Fase 41¶
| Patrón de la Fase 33 (este capítulo) | Sitio de uso en la Fase 41 |
|---|---|
| Lifespan abre sqlite + vault (§1) | src/miniportal/app.py::create_app |
Depends(require_student) (§2) |
Cada ruta bajo routes/journal.py, routes/notes.py, routes/quizzes.py, routes/exams.py |
| Orden de middleware CSRF-después-de-sesión (§3) | Bloque add_middleware en src/miniportal/app.py |
| Parciales HTMX en lugar de SPA (§4) | src/miniportal/templates/*.html, solo static/htmx.min.js |
Léelos como las cuatro piezas con forma de Fase 33 dentro del capítulo de arquitectura de la Fase 41. Cuando la Fase 41 diga "el portal es solo una app FastAPI", lo que quiere decir es "el portal es estos cuatro patrones más el chrome de auth + audit de la Fase 41 encima".
Siguiente: lab/00-minimal-fastapi.md vuelve a la preocupación que marca el ritmo de la Fase 33 — envolver el tutor de gramática con POST /correct.