Python para m-b-core/Prácticas/3 · Un módulo FastAPI como cities/
Práctica 3 de 8

Un módulo FastAPI como cities/

Tu primer endpoint HTTP al estilo de la casa: schemas.py que valida y documenta, utils.py puro, routes.py delgado, errores con contrato y tests con TestClient. Es la forma exacta en que está hecho services/api/app/cities/ en m-b-core.

concepto · el borde HTTP: validar, delegar, responder2 sesionesfastapi · pydantic v2 · TestClient · error_response
ConstruyesGET /v1/locales, GET /v1/locales/{id}, GET /health
AprendesControlador Laravel → ruta FastAPI: FormRequest es un modelo Pydantic; Handler es error_response
Igual que en m-b-corecities/routes.py, cities/schemas.py, shared/errors/, router.py
Terminas con13 tests en verde, ruff y mypy limpios, curl con 200/404/422 reales

Objetivos

  • Escribir un módulo con las cuatro capas de la casa: schemas.py (contrato), data.py/queries.py (I/O), utils.py (puro), routes.py (HTTP).
  • Validar en el borde con Pydantic v2 y Annotated[..., Query(...)], y ver el 422 salir solo.
  • Responder errores con el contrato de m-b-core: error_response(CODE, mensaje) + el status HTTP correcto; nunca 200 con {"error": …}.
  • Registrar el módulo en router.py con build_api_router() y ver el OpenAPI en /docs.
  • Testear una ruta mockeando la función que la ruta importa, y una función pura sin mocks.
Cómo lo harías en Laravel
// routes/api.php
Route::get('locales/{id}', 'LocalController@show');

// LocalController.php
public function show(Request $request, int $id)
{
    $local = Local::find($id);
    if (!$local) {
        return response()->json(['error' => 'no existe'], 404);
    }
    return response()->json(new LocalResource($local));
}
// + un FormRequest para validar, si te acuerdas
Cómo se hace en m-b-core
# locales/routes.py
@router.get("/locales/{local_id}", response_model=LocalDTO)
async def get_local(local_id: int) -> LocalDTO | JSONResponse:
    row = await fetch_local(local_id)                  # I/O, en otro archivo
    if row is None:
        return JSONResponse(
            status_code=404,
            content=error_response(LOCAL_NOT_FOUND, f"Local {local_id} no existe"),
        )
    return to_local_dto(row)                            # función pura, con tests
# la validación de local_id: int la hace FastAPI (422 solo)

Antes de empezar

Necesitas el mini-repo mesa-core-practicas de la práctica 1 con su .venv en Python 3.12, pyproject.toml (ruff/mypy de m-b-core), pytest.ini con asyncio_mode = auto y el Makefile con PYTHONPATH := .. Verifica:

cd mesa-core-practicas
source .venv/bin/activate
python --version            # Python 3.12.x  (con 3.14 no hay wheels de pydantic-core 2.10 ni orjson 3.10)
make tests                  # lo de la práctica 1 y 2 en verde

Agrega a requirements/base.txt y requirements/local.txt lo de esta práctica, con pines exactos como en m-b-core:

requirements/base.txt (agregar)
fastapi==0.115.6
uvicorn==0.34.0
pydantic==2.10.6
orjson==3.10.15
requirements/local.txt (agregar)
httpx==0.28.1        # lo usa TestClient
make install
mkdir -p shared/errors services/api/app/locales/tests
touch shared/errors/__init__.py services/api/app/locales/__init__.py services/api/app/locales/tests/__init__.py

Construcción paso a paso

Escribe cada archivo tú; corre cada comando; compara con "deberías ver". El orden importa: primero el contrato de errores y la app, luego el módulo, luego los tests. Al final tendrás la misma anatomía que cities/.

Paso 1 Errores con contrato: shared/errors/

En m-b-core todos los errores tienen la misma forma y los códigos son constantes con nombre (shared/errors/codes.py): un rename se propaga solo, y el cliente puede hacer if error == "LOCAL_NOT_FOUND". Copiamos exactamente esa forma.

shared/errors/codes.py
"""Códigos de error como constantes con nombre (van en el campo `error` de la respuesta).

Centralizados aquí para que un rename se propague a todos los módulos.
"""

# 400 — validation
VALIDATION_ERROR = "VALIDATION_ERROR"

# 404 — recursos
LOCAL_NOT_FOUND = "LOCAL_NOT_FOUND"

# 500 — internal
INTERNAL_ERROR = "INTERNAL_ERROR"
shared/errors/responses.py
"""Formato estándar de error para toda la API (y los workers)."""

from enum import Enum
from typing import Any


def error_response(
    error_code: str | Enum,
    message: str,
    status_code: int = 400,
    detail: str | None = None,
) -> dict[str, Any]:
    """Crea la respuesta de error estándar.

    Estructura: {"success": False, "error": <CODE>, "message": <texto>, "detail": <opcional>}
    `status_code` no va en el cuerpo: se pasa a JSONResponse; aquí queda como referencia.
    """
    error_value = error_code.value if isinstance(error_code, Enum) else str(error_code)
    response: dict[str, Any] = {"success": False, "error": error_value, "message": message}
    if detail:
        response["detail"] = detail
    return response
shared/errors/__init__.py
"""Error response utilities: mismo contrato que shared/errors en m-b-core."""

from .codes import LOCAL_NOT_FOUND, VALIDATION_ERROR
from .responses import error_response

__all__ = ["error_response", "LOCAL_NOT_FOUND", "VALIDATION_ERROR"]
Por qué un dict y no una excepción. m-b-core usa las dos cosas: error_response() cuando la ruta decide el status (404, 400) y HTTPException(detail={"error": CODE, ...}) cuando quiere abortar desde más adentro. Empezamos por la primera porque hace visible el status en la ruta. En la práctica 7 verás excepciones de dominio mapeadas una sola vez en un handler.

Paso 2 La app, /health y el router

shared/settings.py
"""Configuración centralizada: el ÚNICO módulo que lee variables de entorno.

Como en m-b-core (`shared/settings.py`), es una clase plana que se lee al importar.
En código: `from shared.settings import settings` y luego `settings.ENV`.
"""

import os


class Settings:
    ENV: str = os.getenv("ENV", "dev")
    SERVICE_NAME: str = os.getenv("SERVICE_NAME", "api")
    APP_VERSION: str = os.getenv("APP_VERSION", "dev")


settings = Settings()
services/api/app/main.py
"""Mesa Core (prácticas) — API FastAPI.

Expone:
    - GET /health         (versión + estado)
    - GET /v1/locales…    (módulo locales)
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime

from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

from shared.settings import settings

from .router import build_api_router

IS_PROD = settings.ENV == "prod"
STARTED_AT = datetime.now(UTC)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # Aquí irían pools de DB, clientes HTTP compartidos, etc. (prácticas 2 y 4)
    yield


app = FastAPI(
    title="Mesa Core (prácticas)",
    version=settings.APP_VERSION,
    lifespan=lifespan,
    # En m-b-core la API los deja abiertos también en prod (vicio 38 de la guía);
    # worker y bridge los apagan fuera de staging. Aquí seguimos la versión buena.
    docs_url=None if IS_PROD else "/docs",
    redoc_url=None if IS_PROD else "/redoc",
    openapi_url=None if IS_PROD else "/openapi.json",
    default_response_class=ORJSONResponse,
)


@app.get("/health", tags=["health"])
async def health() -> dict[str, str]:
    """Estado del servicio y versión desplegada (commit sha en prod)."""
    return {
        "status": "ok",
        "version": settings.APP_VERSION,
        "env": settings.ENV,
        "started_at": STARTED_AT.isoformat(),
    }


app.include_router(build_api_router(), prefix="/v1")
services/api/app/router.py
"""Router: cada módulo se incluye aquí con su prefijo (como router.py de m-b-core)."""

from fastapi import APIRouter

from .locales.routes import router as locales_router


def build_api_router() -> APIRouter:
    api = APIRouter()
    api.include_router(locales_router)
    return api
Tres cosas de m-b-core aquí. (1) lifespan en vez del @app.on_event deprecado (m-b-core todavía usa on_event en la API; bridge y worker ya migraron). (2) ORJSONResponse por defecto: serializa 3–5× más rápido. (3) docs_url=None en prod: el mapa de la API no se publica; un atacante enumeró /openapi.json de m-b-core el 16-ago.

Paso 3 schemas.py: el contrato que valida y documenta

Un modelo Pydantic es tu FormRequest y tu Resource a la vez: valida la entrada, serializa la salida y genera el esquema OpenAPI. Cada Field con description aparece en /docs.

services/api/app/locales/schemas.py
"""Modelos de entrada/salida de /v1/locales (Pydantic v2 → OpenAPI)."""

from __future__ import annotations

from pydantic import BaseModel, Field, field_validator


class LocalDTO(BaseModel):
    id: int = Field(..., ge=1, description="Id legacy del local")
    nombre: str = Field(..., min_length=1, description="Nombre comercial")
    pais: str = Field(..., min_length=2, max_length=2, description="ISO 3166-1 alpha-2: 'PE', 'CL'")
    ciudad: str = Field(..., min_length=1)
    zona_horaria: str = Field(..., min_length=1, description="IANA tz, p.ej. 'America/Lima'")
    activo: bool = Field(..., description="EstadoId == 1")

    @field_validator("pais")
    @classmethod
    def pais_mayusculas(cls, v: str) -> str:
        if not v.isupper():
            raise ValueError("pais debe ser ISO 3166-1 alpha-2 en mayúsculas")
        return v


class LocalesResponse(BaseModel):
    total: int = Field(..., ge=0, description="Cantidad de locales devueltos")
    locales: list[LocalDTO] = Field(..., description="Locales activos, ordenados por nombre")
Igual que cities/schemas.py. Compara con CityDTO en m-b-core: country_code con min_length=2, max_length=2 y un field_validator que exige mayúsculas. Es literalmente el mismo patrón. ... como default significa "obligatorio"; from __future__ import annotations arriba, como en todo el repo.

Paso 4 Datos (por ahora en memoria) y utils.py puro

En esta práctica los datos viven en memoria con la misma forma que una fila del legacy (claves = nombres de columna: Id, Nombre, Pais, Depa, EstadoId, ZonaHoraria). En la práctica 4 este archivo se convierte en queries.py con SQL real y legacy_db_read, y nada más cambia: esa es la gracia de separar capas.

services/api/app/locales/data.py
"""Datos en memoria: simulan las filas que en la práctica 4 vendrán de `legacy_db_read`.

Misma forma que una fila de `Local` + `Ubigeo` del legacy: claves con el nombre de columna.
"""

from __future__ import annotations

from typing import Any

LOCALES_ROWS: list[dict[str, Any]] = [
    {"Id": 11, "Nombre": "Maido", "Pais": "PE", "Depa": "Lima", "EstadoId": 1, "ZonaHoraria": "America/Lima"},
    {"Id": 873, "Nombre": "Boragó", "Pais": "CL", "Depa": "Santiago", "EstadoId": 1, "ZonaHoraria": None},
    {"Id": 2246, "Nombre": "Nuema", "Pais": "EC", "Depa": "Quito", "EstadoId": 1, "ZonaHoraria": "America/Guayaquil"},
    {"Id": 999, "Nombre": "Cerrado SAC", "Pais": "PE", "Depa": "Lima", "EstadoId": 0, "ZonaHoraria": "America/Lima"},
]


async def fetch_locales(pais: str | None = None) -> list[dict[str, Any]]:
    """Devuelve filas de locales; en la práctica 4 esta función pasa a `queries.py` con SQL real."""
    rows = LOCALES_ROWS
    if pais is not None:
        rows = [r for r in rows if r["Pais"] == pais]
    return list(rows)


async def fetch_local(local_id: int) -> dict[str, Any] | None:
    for row in LOCALES_ROWS:
        if row["Id"] == local_id:
            return row
    return None

Nota: ruff format va a partir esos diccionarios en varias líneas (línea máxima 100). Déjalo: el formato lo decide la herramienta, no tú.

services/api/app/locales/utils.py
"""Funciones puras del módulo locales: sin I/O, sin estado, fáciles de testear."""

from __future__ import annotations

from typing import Any

from .schemas import LocalDTO

ESTADO_ACTIVO = 1
DEFAULT_TIMEZONE = "America/Lima"


def to_local_dto(row: dict[str, Any]) -> LocalDTO:
    """Convierte una fila legacy (claves = columnas) en el DTO público.

    `ZonaHoraria` NULL cae a DEFAULT_TIMEZONE; la práctica 6 lo reemplaza por `tz_for()`.
    """
    return LocalDTO(
        id=row["Id"],
        nombre=row["Nombre"],
        pais=row["Pais"],
        ciudad=row["Depa"],
        zona_horaria=row.get("ZonaHoraria") or DEFAULT_TIMEZONE,
        activo=row["EstadoId"] == ESTADO_ACTIVO,
    )


def solo_activos(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [r for r in rows if r["EstadoId"] == ESTADO_ACTIVO]


def ordenar_por_nombre(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(rows, key=lambda r: r["Nombre"].casefold())
Fíjate en row.get("ZonaHoraria") or DEFAULT_TIMEZONE. row["ZonaHoraria"] con None no falla (la clave existe), pero row["ZonaHoraria"] en una fila que no trae la columna sí lanzaría KeyError (trampa 5 de la guía). Y or cubre None y "": la cadena vacía es falsy (trampa 2). ESTADO_ACTIVO = 1 es la guideline 1 de m-b-core: nada de == 1 suelto (hoy en m-b-core hay 153 EstadoId = 1 incrustados; no los repitas).

Paso 5 routes.py: delgado

services/api/app/locales/routes.py
"""GET /v1/locales y GET /v1/locales/{local_id}.

Pipeline (igual que cities/ en m-b-core):
    1. Validar query params      → Pydantic/Annotated en la firma
    2. Traer filas               → data.py (práctica 4: queries.py con SQL)
    3. Transformar               → utils.py (funciones puras)
    4. Responder                 → response_model / error_response
"""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse

from shared.errors import LOCAL_NOT_FOUND, error_response

from .data import fetch_local, fetch_locales
from .schemas import LocalDTO, LocalesResponse
from .utils import ordenar_por_nombre, solo_activos, to_local_dto

router = APIRouter(tags=["locales"])


@router.get("/locales", response_model=LocalesResponse)
async def list_locales(
    pais: Annotated[
        str | None,
        Query(
            min_length=2, max_length=2, pattern="^[A-Z]{2}$", description="ISO alpha-2, p.ej. PE"
        ),
    ] = None,
) -> LocalesResponse:
    """Locales activos, opcionalmente filtrados por país, ordenados por nombre."""
    rows = await fetch_locales(pais)
    activos = ordenar_por_nombre(solo_activos(rows))
    return LocalesResponse(total=len(activos), locales=[to_local_dto(r) for r in activos])


@router.get("/locales/{local_id}", response_model=LocalDTO)
async def get_local(local_id: int) -> LocalDTO | JSONResponse:
    """Un local por id. 404 con código de error si no existe."""
    row = await fetch_local(local_id)
    if row is None:
        return JSONResponse(
            status_code=404,
            content=error_response(LOCAL_NOT_FOUND, f"Local {local_id} no existe"),
        )
    return to_local_dto(row)
Cuenta las líneas de lógica en cada ruta: tres. Traer, transformar, responder. Todo lo que se puede equivocar (mapeo, filtro, orden) está en utils.py, que se testea sin levantar nada. Annotated[str | None, Query(...)] es la forma moderna (evita el B008 de ruff que m-b-core ignora por esto). El -> LocalDTO | JSONResponse le dice a mypy la verdad: la ruta devuelve una cosa u otra.

Paso 6 Levantar y probar con curl

make run-api          # = ENV=dev uvicorn services.api.app.main:app --port 8000 --reload
Deberías ver
INFO:     Started server process [42141]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

En otra terminal:

curl -s -i localhost:8000/health
curl -s "localhost:8000/v1/locales?pais=PE"
curl -s -i "localhost:8000/v1/locales?pais=peru"
curl -s -i localhost:8000/v1/locales/404404
curl -s localhost:8000/v1/locales/873
Deberías ver (recortado)
HTTP/1.1 200 OK
{"status":"ok","version":"dev","env":"dev","started_at":"2026-08-17T12:05:25.705034+00:00"}

{"total":1,"locales":[{"id":11,"nombre":"Maido","pais":"PE","ciudad":"Lima","zona_horaria":"America/Lima","activo":true}]}

HTTP/1.1 422 Unprocessable Entity
{"detail":[{"type":"string_too_long","loc":["query","pais"],"msg":"String should have at most 2 characters","input":"peru","ctx":{"max_length":2}}]}

HTTP/1.1 404 Not Found
{"success":false,"error":"LOCAL_NOT_FOUND","message":"Local 404404 no existe"}

{"id":873,"nombre":"Boragó","pais":"CL","ciudad":"Santiago","zona_horaria":"America/Lima","activo":true}
Mira el 873. Boragó está en Santiago y sale con America/Lima: el fallback de utils.py es por defecto Perú. Es un bug a propósito, el mismo que la guía llama "el código sabe que existe Perú"; la práctica 6 lo arregla con tz_for(zona_horaria, pais). Anótalo.

Abre localhost:8000/docs: los dos endpoints, los parámetros con su descripción, los modelos con sus restricciones. Todo salió de schemas.py y de la firma de la ruta; no escribiste documentación. Y prueba que en prod se apaga:

ENV=prod PYTHONPATH=. .venv/bin/uvicorn services.api.app.main:app --port 8001 &
curl -s -o /dev/null -w "%{http_code}\n" localhost:8001/docs; curl -s -o /dev/null -w "%{http_code}\n" localhost:8001/openapi.json
kill %1
Deberías ver
404
404

Paso 7 Tests: puros sin mocks, rutas con TestClient

Dos tipos, según el árbol de decisión de TESTING.md: la función pura se parametriza; la ruta se prueba con TestClient mockeando la función que la ruta importa (services.api.app.locales.routes.fetch_locales), no la ruta ni la capa de datos entera.

services/api/app/locales/tests/test_utils.py
"""Funciones puras → parametrizar bordes, sin mocks."""

import pytest

from services.api.app.locales.utils import ordenar_por_nombre, solo_activos, to_local_dto

FILA_LIMA = {"Id": 11, "Nombre": "Maido", "Pais": "PE", "Depa": "Lima", "EstadoId": 1, "ZonaHoraria": "America/Lima"}


def test_to_local_dto_mapea_columnas_legacy():
    dto = to_local_dto(FILA_LIMA)
    assert dto.id == 11
    assert dto.ciudad == "Lima"
    assert dto.zona_horaria == "America/Lima"
    assert dto.activo is True


@pytest.mark.parametrize(
    "zona, esperada",
    [("America/Santiago", "America/Santiago"), (None, "America/Lima"), ("", "America/Lima")],
)
def test_to_local_dto_zona_horaria_con_fallback(zona, esperada):
    dto = to_local_dto({**FILA_LIMA, "ZonaHoraria": zona})
    assert dto.zona_horaria == esperada


def test_solo_activos_filtra_estado_distinto_de_1():
    rows = [FILA_LIMA, {**FILA_LIMA, "Id": 12, "EstadoId": 0}, {**FILA_LIMA, "Id": 13, "EstadoId": 3}]
    assert [r["Id"] for r in solo_activos(rows)] == [11]


def test_ordenar_por_nombre_ignora_mayusculas():
    rows = [{**FILA_LIMA, "Nombre": "zeta"}, {**FILA_LIMA, "Nombre": "Alfa"}, {**FILA_LIMA, "Nombre": "beta"}]
    assert [r["Nombre"] for r in ordenar_por_nombre(rows)] == ["Alfa", "beta", "zeta"]
services/api/app/locales/tests/test_routes.py
"""Rutas → TestClient + patch de la función que la ruta importa (un nivel abajo, no la ruta)."""

from unittest.mock import AsyncMock, patch

from fastapi.testclient import TestClient

from services.api.app.main import app

client = TestClient(app)

_MOCK_LIST = "services.api.app.locales.routes.fetch_locales"
_MOCK_ONE = "services.api.app.locales.routes.fetch_local"

LIMA = {"Id": 11, "Nombre": "Maido", "Pais": "PE", "Depa": "Lima", "EstadoId": 1, "ZonaHoraria": "America/Lima"}
CERRADO = {**LIMA, "Id": 999, "Nombre": "Cerrado SAC", "EstadoId": 0}


def test_list_200_solo_activos_y_ordenados():
    with patch(_MOCK_LIST, new=AsyncMock(return_value=[CERRADO, LIMA])):
        r = client.get("/v1/locales")
    assert r.status_code == 200
    body = r.json()
    assert body["total"] == 1
    assert body["locales"][0] == {
        "id": 11, "nombre": "Maido", "pais": "PE", "ciudad": "Lima",
        "zona_horaria": "America/Lima", "activo": True,
    }


def test_list_pasa_el_pais_a_la_capa_de_datos():
    fake = AsyncMock(return_value=[LIMA])
    with patch(_MOCK_LIST, new=fake):
        r = client.get("/v1/locales", params={"pais": "PE"})
    assert r.status_code == 200
    fake.assert_awaited_once_with("PE")


def test_list_422_si_el_pais_no_es_alpha2():
    r = client.get("/v1/locales", params={"pais": "peru"})  # lo valida FastAPI; sin mock
    assert r.status_code == 422
    assert r.json()["detail"][0]["loc"] == ["query", "pais"]


def test_get_200():
    with patch(_MOCK_ONE, new=AsyncMock(return_value=LIMA)):
        r = client.get("/v1/locales/11")
    assert r.status_code == 200
    assert r.json()["nombre"] == "Maido"


def test_get_404_con_codigo_de_error():
    with patch(_MOCK_ONE, new=AsyncMock(return_value=None)):
        r = client.get("/v1/locales/404404")
    assert r.status_code == 404
    assert r.json() == {"success": False, "error": "LOCAL_NOT_FOUND", "message": "Local 404404 no existe"}


def test_get_422_si_el_id_no_es_entero():
    r = client.get("/v1/locales/abc")
    assert r.status_code == 422


def test_health_devuelve_version():
    r = client.get("/health")
    assert r.status_code == 200
    assert r.json()["status"] == "ok"
    assert "version" in r.json()
make tests
Deberías ver
asyncio: mode=Mode.AUTO, asyncio_default_fixture_loop_scope=None
collecting ... collected 13 items

services/api/app/locales/tests/test_routes.py::test_list_200_solo_activos_y_ordenados PASSED [  7%]
services/api/app/locales/tests/test_routes.py::test_list_pasa_el_pais_a_la_capa_de_datos PASSED [ 15%]
services/api/app/locales/tests/test_routes.py::test_list_422_si_el_pais_no_es_alpha2 PASSED [ 23%]
services/api/app/locales/tests/test_routes.py::test_get_200 PASSED       [ 30%]
services/api/app/locales/tests/test_routes.py::test_get_404_con_codigo_de_error PASSED [ 38%]
services/api/app/locales/tests/test_routes.py::test_get_422_si_el_id_no_es_entero PASSED [ 46%]
services/api/app/locales/tests/test_routes.py::test_health_devuelve_version PASSED [ 53%]
services/api/app/locales/tests/test_utils.py::test_to_local_dto_mapea_columnas_legacy PASSED [ 61%]
services/api/app/locales/tests/test_utils.py::test_to_local_dto_zona_horaria_con_fallback[America/Santiago-America/Santiago] PASSED [ 69%]
services/api/app/locales/tests/test_utils.py::test_to_local_dto_zona_horaria_con_fallback[None-America/Lima] PASSED [ 76%]
services/api/app/locales/tests/test_utils.py::test_to_local_dto_zona_horaria_con_fallback[-America/Lima] PASSED [ 84%]
services/api/app/locales/tests/test_utils.py::test_solo_activos_filtra_estado_distinto_de_1 PASSED [ 92%]
services/api/app/locales/tests/test_utils.py::test_ordenar_por_nombre_ignora_mayusculas PASSED [100%]

============================== 13 passed in 0.78s ==============================
Por qué _MOCK_LIST = "services.api.app.locales.routes.fetch_locales" y no …locales.data.fetch_locales. Porque routes.py hizo from .data import fetch_locales: ese nombre vive ahora en el módulo routes. Si patcheas data.fetch_locales, la ruta sigue usando su copia y el mock no se activa. Es la "regla del mock boundary" de TESTING.md: patchea donde se usa. AsyncMock porque la función es async def; assert_awaited_once_with("PE") verifica que la ruta pasó el parámetro a la capa de datos sin transformarlo.

Paso 8 Lint, tipos, README del módulo y commit

make lint             # ruff check . && ruff format --check .
make typecheck        # mypy services/api/app shared --explicit-package-bases
Deberías ver
All checks passed!
16 files already formatted
Success: no issues found in 16 source files

Si ruff format --check te lista archivos, corre ruff format . y ya. Cada módulo de m-b-core tiene un README.md con el contrato; escribe el tuyo:

services/api/app/locales/README.md
# locales

`GET /v1/locales?pais=PE` — locales activos ordenados por nombre.
`GET /v1/locales/{id}` — un local; `404 {"success": false, "error": "LOCAL_NOT_FOUND", …}` si no existe.

Capas: `routes.py` (HTTP, delgado) → `data.py` (I/O; en la práctica 4 pasa a `queries.py`) → `utils.py` (puro).
Contrato de salida: `schemas.py` (`LocalDTO`, `LocalesResponse`).
Tests: `tests/test_utils.py` (puros, parametrizados) · `tests/test_routes.py` (TestClient + patch de `fetch_*` en `routes`).
git add -A
git commit -m "feat(locales): módulo locales con schemas, utils puros, routes y tests"

Formato de commit de m-b-core: type(scope): description (guideline 9). Un cambio lógico por commit, tests en verde antes.

Entiende el código: qué de esto es igual en m-b-core

Lo que hicisteDónde está en m-b-coreQué mirar
schemas.py con Field, field_validator, response modelsservices/api/app/cities/schemas.pyCityDTO.country_code con min_length=2, max_length=2 y validador de mayúsculas; CitiesResponse con dos listas.
routes.py delgado: validar → traer → transformar → responderservices/api/app/cities/routes.pyEl docstring del módulo enumera el pipeline; list_cities valida lat/lng, llama fetch_cities, usa to_city_dto, devuelve CitiesResponse o JSONResponse(400, error_response(...)).
utils.py puroservices/api/app/cities/utils.py"All functions are stateless and have no I/O side effects": slugify, haversine_km, build_thumbnail.
error_response + códigos con nombreshared/errors/responses.py, shared/errors/codes.pyMisma firma y misma forma de respuesta; los códigos agrupados por status con comentario.
build_api_router()services/api/app/router.pyCada módulo con su prefijo: api.include_router(cities_router), …(auth_router, prefix="/auth").
TestClient + patch("…routes.fetch_x", new=AsyncMock(...))services/api/app/cities/tests/test_routes.py_MOCK_PATH = "services.api.app.cities.routes.fetch_cities" y un helper _patch(rows). Idéntico.
Annotated[float | None, Query(ge=-90.0, le=90.0, description=...)]cities/routes.py (parámetros lat, lng)La validación declarativa en la firma; el 422 lo genera FastAPI.
lifespan, ORJSONResponse, docs_urlservices/api/app/main.py, services/worker/app/main.py:32-34La API usa on_event (deprecado) y deja /docs abierto en prod; worker y bridge lo apagan. Tú hiciste la versión buena.

Lo que todavía no es igual: los datos vienen de memoria y no de legacy_db_read (práctica 4), no hay cache (shared/cache.remember, práctica 5), la zona horaria no sale de tz_for() (práctica 6) y los errores no vienen de excepciones de dominio (práctica 7). El esqueleto, sí.

Con Claude

En esta práctica escribes tú las cuatro capas por primera vez (regla 1: la primera vez, a mano). Claude sirve para entender y para revisar. Pedidos que valen la pena:

Así sí

Compara mi locales/routes.py con services/api/app/cities/routes.py de m-b-core y dime en qué me desvío del patrón. No cambies nada; lista las diferencias. ¿Por qué patch("services.api.app.locales.data.fetch_locales") no funcionaría en mi test de ruta? Explícamelo con cómo resuelve Python los nombres importados. Genera cinco casos de borde para to_local_dto que yo no haya cubierto (filas raras del legacy) y dime cuáles merecen test según TESTING.md §0.

Así no: "hazme el módulo locales". Saldría algo parecido, sin que sepas por qué está partido en cuatro archivos, y probablemente con la lógica dentro de la ruta.

Si algo falla

ModuleNotFoundError: No module named 'services' o 'shared'

Falta PYTHONPATH=. o no estás en la raíz del repo. make tests/make run-api lo exportan; si corres pytest a mano: PYTHONPATH=. pytest. En m-b-core es igual (el Makefile exporta PYTHONPATH := . y ci.yml pone PYTHONPATH: ${{ github.workspace }}).

Failed to build installable wheels … orjson, pydantic-core al hacer pip install

Tu .venv se creó con un Python distinto de 3.12 (con 3.14 no hay wheels para pydantic-core 2.27/orjson 3.10). Bórralo y créalo con 3.12: python3.12 -m venv .venv (o uv venv --python 3.12 .venv --seed). Es la trampa 14 de la guía: cada proyecto con su Python.

Olvidaste un await: RuntimeWarning: coroutine 'fetch_local' was never awaited y row is None nunca es verdadero
row = fetch_local(11)      # sin await
print(type(row))           # <class 'coroutine'>
print(row is None)         # False — ¡una corrutina nunca es None! → tu 404 no salta nunca

Salida real de este repo. Toda función async def se llama con await. mypy lo marca si la firma dice -> dict | None y le pasas una corrutina; los tests lo pillan porque el 404 no ocurre.

La ruta devuelve 500 con ResponseValidationError
fastapi.exceptions.ResponseValidationError: 4 validation errors:
  {'type': 'missing', 'loc': ('response', 'pais'), 'msg': 'Field required', ...}

Devolviste un dict que no cumple el response_model (faltan campos, o el tipo no coincide). Es FastAPI protegiendo el contrato: mejor un 500 en el test que un JSON incompleto al cliente. Devuelve el modelo (to_local_dto(row)) o completa el dict.

Tu 404 sale con status 200
@router.get("/y")
async def y():
    return {"success": False, "error": "LOCAL_NOT_FOUND"}   # → 200 OK con el error dentro

Devolver un dict no cambia el status. Es el vicio 12 de la guía ("200 con error") con sintaxis Python. Usa JSONResponse(status_code=404, content=error_response(...)) o raise HTTPException(status_code=404, detail={...}).

El test de ruta pasa aunque rompas data.py

Es lo esperado: el test de ruta mockea fetch_*, así que no ejercita data.py. Los tests de la capa de datos van aparte (en la práctica 4, contra legacy_db_read mockeado en el binding de queries.py). Si data.py hoy no tiene tests propios es porque es un stub temporal; anótalo en el README.

Listo cuando

  • make tests muestra 13 passed; make lint y make typecheck limpios.
  • curl devuelve 200 con el JSON del contrato, 404 con {"success": false, "error": "LOCAL_NOT_FOUND", …} y 422 cuando pais no es alpha-2, y lo viste en tu terminal.
  • Con ENV=prod, /docs y /openapi.json responden 404.
  • Puedes explicar por qué el mock de la ruta apunta a locales.routes.fetch_locales y no a locales.data.fetch_locales.
  • Sabes decir qué archivo tocarías (y cuál no) si mañana los locales vienen de una base de datos.
  • Anotaste el bug de Boragó (America/Lima para un local de Santiago) para la práctica 6.

Siguiente

En la práctica 4 reemplazas data.py por queries.py con SQL parametrizado (:local_id) sobre legacy_db_read, y aprendes a testear esa capa con legacy_read_mock patcheado en el binding local, pineando cláusulas y parámetros. Nada de routes.py, schemas.py ni utils.py cambia: esa es la prueba de que las capas están bien partidas.