Rutas y controladores¶
Las rutas son FastAPI puro. milpa solo añade el auto-montaje: declaras un
APIRouter en un controller de tu módulo y la app lo monta sola, sin registrarlo a mano.
Un controller mínimo¶
# app/Modules/Example/Http/controller.py
from fastapi import APIRouter
router = APIRouter(prefix="/example", tags=["example"])
@router.get("/ping")
def ping() -> dict[str, str]:
return {"module": "Example", "status": "ok"}
Eso es todo. iter_routers() (del Registry) escanea Modules/<X>/Http/ de forma
recursiva, recoge cualquier variable APIRouter a nivel de módulo y create_app()
la incluye con app.include_router(router). (Ver Monolito modular.)
Convenciones:
- Pon los controllers bajo
Modules/<Tu módulo>/Http/. - La variable del router debe estar a nivel de módulo (no dentro de una función).
- Puedes tener varios archivos y varios routers; se descubren todos (deduplicados por identidad).
Controllers class-based (estilo Spring)¶
Si prefieres agrupar endpoints en una clase (≈ @RestController de Spring), usa @Controller
con @Get/@Post/@Put/@Patch/@Delete sobre los métodos. Se auto-monta igual que un APIRouter, y
convive con el estilo función de arriba:
from milpa.Core.Http import Controller, Get, Post
@Controller("/cats", tags=["cats"])
class CatsController:
@Get("/")
def index(self) -> list[str]: ...
@Get("/{cat_id}")
def show(self, cat_id: int) -> dict[str, int]: ... # path param tipado
@Post("/", status_code=201)
def store(self, body: CatInput) -> dict[str, str]: ... # body Pydantic
Los decoradores de verbo aceptan los mismos kwargs que FastAPI (status_code, response_model,
dependencies, summary, …). self se resuelve solo (el controller se instancia una vez). Para
proteger métodos: inyecta el usuario con CurrentUser/Depends(guarded("jwt")), o usa
@Roles("admin") / @Can("note.create") sobre el método (ver Autenticación).
Renderizar una vista¶
Para devolver HTML (Jinja2) en vez de JSON, usa el helper view():
from fastapi.responses import HTMLResponse
from milpa.Core.View import view
@router.get("/welcome", response_class=HTMLResponse)
def welcome() -> HTMLResponse:
return view("example/welcome") # Modules/Example/Resources/Views/welcome.html.j2
El prefijo example/ es el namespace del módulo. Ver Vistas.
Encolar trabajo desde una ruta¶
Un endpoint que dispara una task de Celery (no bloquea la respuesta):
from app.Modules.Example.Jobs.HelloJob import hello_world
@router.get("/hello")
def dispatch_hello(name: str = "mundo") -> dict[str, str]:
hello_world.delay(name=name) # encola; lo procesa "jornal queue work"
return {"queued": True, "name": name}
Ver Colas y tareas.
Proteger rutas¶
Con una dependency a nivel de router (del módulo)¶
Patrón idiomático para seguridad propia del módulo: una dependency en el APIRouter
corre antes de cada ruta del router y viaja con el módulo (no es global).
# app/Modules/Example/Http/secured.py
from fastapi import APIRouter, Depends, Header, HTTPException, status
def require_api_key(x_api_key: str = Header(default="")) -> None:
if x_api_key != "demo-secret":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key inválida")
router = APIRouter(
prefix="/example/secured",
tags=["example"],
dependencies=[Depends(require_api_key)], # aplica a TODAS las rutas del router
)
@router.get("/ping")
def secured_ping() -> dict[str, str]:
return {"module": "Example", "scope": "secured", "status": "ok"}
Con tokens OAuth2 de Passport¶
Si migras desde Laravel y validas tokens de Passport, usa las dependencies de
milpa/Core/Auth. Ver Autenticación:
from milpa.Core.Auth import get_current_token, require_scopes, TokenPrincipal
@router.get("/profile")
def profile(principal: TokenPrincipal = Depends(get_current_token)) -> dict:
return {"user_id": principal.user_id}
@router.post("/admin")
def admin(principal: TokenPrincipal = Depends(require_scopes("admin"))) -> dict:
return {"ok": True}
Dependency global vs. por router¶
| Alcance | Cómo | Cuándo |
|---|---|---|
| Global | FastAPI(..., dependencies=[...]) en create_app |
algo que aplica a TODA la app (ej. el locale) |
| Por router | APIRouter(..., dependencies=[...]) |
seguridad/reglas propias de un módulo |
Prefiere por router para que la lógica quede dentro del módulo (extraíble).
Manejo de errores (RFC 9457 — Problem Details)¶
No traduzcas a mano cada error de negocio a HTTPException en el controller. Lanza un
error de dominio (milpa/Core/Errors) desde donde ocurra (service, repository) y un
handler global (milpa/Core/Http/ExceptionHandler.py, ya montado por create_app) lo
convierte al sobre JSON estándar de la industria: RFC 9457 Problem Details
(application/problem+json).
from milpa.Core.Errors import ResourceNotFoundError, DomainError
# En un service / repository (NO lo atrapes en el controller):
raise ResourceNotFoundError("La compañía 7 no existe", details={"id": 7})
# raise DomainError("Saldo insuficiente", error_code="insufficient_funds", status_code=402, title="Payment Required")
Respuesta (status según el error; aquí 404), Content-Type: application/problem+json:
{
"type": "about:blank",
"title": "Resource not found",
"status": 404,
"detail": "La compañía 7 no existe",
"code": "resource_not_found",
"errors": { "id": 7 }
}
- Mapeo del
DomainErrora los campos RFC:title(resumen estable del tipo),status,detail(=message, la ocurrencia),code(=error_code, estable, los clientes ramifican en él) yerrors(=details, opcional). - Subclases listas:
ResourceNotFoundError(404),ConflictError(409),UnauthorizedError(401),ForbiddenError(403). ODomainErrordirecto conerror_code/status_code/titlea mano. type:about:blankpor default (RFC-correcto). Si publicas docs de errores, ponPROBLEM_BASE_URLen.envy eltypeapuntará a<base>/<code>.- Una sola forma para TODO: la validación
422de Pydantic sale igual (code: "validation_error", conerrors: {campo: [mensajes]}), losHTTPException(auth/404/405…) también, y cualquier excepción no prevista cae en el catch-all →500genérico (code: "internal_error") con el traceback completo al log y sin filtrar internals en la respuesta.
@Fallback — catch-all raíz que respeta los estáticos¶
¿Sirves una SPA cuyo router del cliente vive en la raíz (/, /perfil, /ajustes…)?
Querrías un único controller que devuelva el shell para cualquier ruta:
@Get("/{path:path}") con prefijo "". Pero registrado como ruta normal se comería los
estáticos.
El problema del orden. create_app incluye los routers de los controllers antes de
montar /static, /vite y /status. Y en Starlette gana el primer match de
app.routes. Un catch-all raíz registrado como ruta normal queda antes de esos mounts y
se traga /static/..., /vite/... y el health check (404 planos en lugar de los assets).
Por eso, hasta ahora, una SPA debía usar un prefijo propio (/app) + un redirect de
/ para no chocar con nadie.
La solución estilo milpa. El decorador @Fallback marca esa ruta para registrarla al
final, después de los mounts. El @Controller no la mete en su router (la aparta), y
create_app la monta tras /static, /vite y /status. Como esos ya ganaron su match, el
catch-all solo recoge lo que nadie reclamó:
from fastapi import Request
from fastapi.responses import HTMLResponse
from milpa.Core.Http import Controller, Fallback, Get
from milpa.Core.View import view
@Controller("", tags=["spa"])
class SpaController:
@Fallback
@Get("/{path:path}")
def shell(self, request: Request, path: str) -> HTMLResponse:
del path # solo participa en el routing; el shell es idéntico
return view("mi/shell")
| Forma tradicional | Estilo milpa | |
|---|---|---|
| Cómo | prefijo propio /app + @Get("/") con RedirectResponse("/app") |
un @Fallback @Get("/{path:path}") en la raíz |
| Coste | el cliente vive bajo /app; / redirige |
el cliente vive en /; cero plomería |
@Fallback se combina con un verbo (el verbo da el path y el método; @Fallback solo
decide cuándo se registra). /api, /static, /vite y /status siguen ganando porque
se montaron antes — el catch-all nunca los pisa. El discovery es el mismo del Registry
(dinámico, no importa Modules estático).
El endpoint /status¶
El único endpoint que registra el kernel directamente: devuelve el nombre del servicio,
los módulos montados y un status: ok. Útil para health checks.