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 verINFO: 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
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()
Deberías verasyncio: 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 verAll 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.