Saltar a contenido

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 DomainError a los campos RFC: title (resumen estable del tipo), status, detail (= message, la ocurrencia), code (= error_code, estable, los clientes ramifican en él) y errors (= details, opcional).
  • Subclases listas: ResourceNotFoundError (404), ConflictError (409), UnauthorizedError (401), ForbiddenError (403). O DomainError directo con error_code/status_code/title a mano.
  • type: about:blank por default (RFC-correcto). Si publicas docs de errores, pon PROBLEM_BASE_URL en .env y el type apuntará a <base>/<code>.
  • Una sola forma para TODO: la validación 422 de Pydantic sale igual (code: "validation_error", con errors: {campo: [mensajes]}), los HTTPException (auth/404/405…) también, y cualquier excepción no prevista cae en el catch-all → 500 gené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.

Siguiente paso

Consola (jornal).