Apariencia
📙 Clase 7 — Comunicación y seguridad en microservicios
Python para Backend · 2026-08-25 · Carpeta:
02-Ejercicios/Clase-07⬅️ Volver al índice de clases
🎯 Qué aprendí
- Comunicación síncrona REST entre microservicios (
httpx.AsyncClient), con manejo de errores distribuidos (timeouts,502/503) - API Gateway Pattern:
proxy()genérico + middleware deX-Correlation-ID, con los 3 microservicios reales detrás, nunca expuestos directamente - Trazabilidad de punta a punta con un solo
X-Correlation-ID, verificada con evidencia real (el mismo ID en los logs de las 3 terminales) - Autenticación con JWT: anatomía del token, firma HMAC-SHA256, esquema Bearer, login y validación compartida entre microservicios (mismo
JWT_SECRET) - Roles y permisos (
require_role) y aislamiento de datos por usuario (cada quien ve solo lo suyo, salvo el admin) - Repository pattern + SQLAlchemy 2.0, con persistencia real (ya no en memoria, como en la Clase 6)
- Migraciones con Alembic y database-per-service:
users_service,products_serviceyorders_service, cada uno con su propia base de datos products_serviceyorders_servicearmados completos, de cero, con Postgres propio- Testing real: 18 casos documentados en Postman (directo vs. a través del gateway) más el primer test automatizado con
pytest - 20 ejercicios graduados, resueltos con evidencia real en Postman
🗺️ Índice
Ancla directo a cualquier sección — todos los enlaces apuntan dentro de esta misma página (
01-Clases/Clase-07). La numeración que ves (1., 2., 3.…) es la que ya trae cada título — no es una numeración aparte, para que coincida siempre con el panel "En esta página" de la derecha.
- 🎯 Qué aprendí
- 📖 PARTE TEÓRICA
- 💻 PARTE PRÁCTICA
- 🗂️ 5. Estructura del proyecto (02-Ejercicios/Clase-07/project)
- 🚪 6. api_gateway/app/main.py — el gateway
- 🧩 7. orders_service/app/service.py — OrderService
- 🗺️ Diagrama: las reglas de negocio de create(), como flujo
- 🌐 8. orders_service/app/clients/products_client.py — el cliente REST
- 🔐 users_service/app/routers/auth.py y app/security.py — login y JWT
- 🗺️ Diagrama: los 3 microservicios y sus 3 bases de datos, separados
- 🧱 9. orders_service completo — el resto de los archivos
- 🗂️ 10. products_service — el catálogo, armado de cero
- 🚀 Levantar, migrar y verificar users_service (directo y a través del gateway)
- 🗄️ Alembic — creando la tabla users de verdad
- 🆚 Probar directo vs. a través del gateway — paso a paso de los dos caminos
- 🌱 scripts/create_admin.py — sembrar el primer usuario admin
- 🧪 tests/test_health.py — el primer test del servicio
- 🔎 Evidencia real del camino 🔴 Directo, caso por caso (Postman)
- 🟢 Evidencia real del camino 🟢 Gateway, caso por caso (Postman)
- ⚙️ .env.example real — dos correcciones sobre lo que se había armado
- 🧩 11. Ejercicio resuelto — manejo de microservicios (crear un pedido, de punta a punta)
- 🏋️ 12. EJERCICIOS CON SOLUCIÓN
- Ejercicio 1 — Registrar tu propio segundo usuario, a través del gateway
- Ejercicio 2 — Login por el gateway, guardando el token en una variable propia
- Ejercicio 3 — Confirmar que tu usuario nuevo NO puede listar usuarios
- Ejercicio 4 — El único que va DIRECTO: comparar contra el gateway
- Ejercicio 5 — Crear tu primer producto (nunca probado en Postman hasta ahora)
- Ejercicio 6 — Desactivar ese producto, y volver a intentarlo
- Ejercicio 7 — Confirmar qué endpoints de productos son públicos y cuáles no
- Ejercicio 8 — Tu primer test automático en Postman (pm.test)
- Ejercicio 9 — Provocar un 409 real: pedir más stock del que hay
- Ejercicio 10 — Pedir un producto que ya desactivaste
- Ejercicio 11 — Filtrar productos por stock mínimo
- Ejercicio 12 — Actualizar un producto con PATCH parcial
- Ejercicio 13 — Aislamiento: un usuario normal solo ve SUS pedidos
- Ejercicio 14 — Intentar consultar el pedido de otra persona
- Ejercicio 15 — Apagar products_service y ver el 503 real, a través del gateway
- Ejercicio 16 — Seguir un mismo correlation_id en las 3 terminales
- Ejercicio 17 — Reto de código: cancelar un pedido
- Ejercicio 18 — Reto de código: migración nueva con Alembic
- Ejercicio 19 — Reto de código: cambiar la contraseña
- Ejercicio 20 — Reto final: el mismo flujo, pero en un script Python
- ❓ Preguntas y respuestas (autoevaluación)
- 📎 Apuntes relacionados
- ➡️ Siguiente
📖 PARTE TEÓRICA
📚 1. Definiciones clave
Comunicación entre microservicios
| Término | Qué es | Se profundiza en |
|---|---|---|
httpx | Librería de Python para hacer requests HTTP; soporta modo asíncrono (AsyncClient). Es el cliente que usa orders_service para hablarle a products_service por REST. | Sección 2 |
httpx.AsyncClient | Cliente HTTP asíncrono; se abre con async with para que la conexión se cierre sola al terminar (aunque haya error). | Sección 2 |
Correlation ID (X-Correlation-ID) | Identificador único que viaja como header HTTP en cada llamada de una cadena de microservicios, para poder rastrear un mismo pedido en los logs de todos los servicios por los que pasó. | Sección 2 |
| Timeout | Tiempo máximo que se espera una respuesta antes de abortar la llamada — evita quedar "colgado" para siempre si el otro servicio no responde. | Sección 2 |
| Manejo de errores distribuidos | Traducir los errores que puede dar un servicio remoto (timeout, caído, 404, 500) en respuestas HTTP propias y consistentes, en vez de dejar pasar la excepción cruda hasta el cliente final. | Sección 2 |
502 Bad Gateway | El servicio actual recibió una respuesta inválida/corrupta de OTRO servicio del que depende. | Sección 2 |
503 Service Unavailable | El servicio del que se depende no está disponible (caído, o tardó más que el timeout). | Sección 2 |
Cliente REST (app/clients/) | Módulo dedicado a encapsular las llamadas HTTP hacia UN servicio externo específico — así el resto del código no llama a httpx directo, llama a una función con nombre de negocio (get_product). | Sección 2 |
API Gateway
| Término | Qué es | Se profundiza en |
|---|---|---|
| API Gateway | Un microservicio más, cuya única función es ser la puerta de entrada única de la API: recibe todas las peticiones externas y las reenvía (proxy) al microservicio real que corresponde. No contiene lógica de negocio. | Sección 3 |
| Reverse proxy / proxy inverso | Un servidor que recibe una petición, la reenvía a OTRO servidor (el "upstream"), y devuelve la respuesta de ese otro servidor como si fuera propia. Es lo que hace la función proxy() del gateway. | Sección 3 |
| Middleware (FastAPI) | Una función que se ejecuta alrededor de cada request/response que pasa por la app — antes de que llegue al endpoint y después de que este responde. Se declara con @app.middleware("http"). | Sección 3 |
call_next | Dentro de un middleware, es la función que continúa la cadena: le pasa el request al siguiente middleware o al endpoint final, y devuelve su response. | Sección 3 |
request.state | Un "cajón" del objeto Request de FastAPI para guardar datos propios durante el ciclo de vida de esa petición (aquí, el correlation_id) y que cualquier parte del código que reciba ese mismo request los pueda leer. | Sección 3 |
uuid4() | Genera un identificador único aleatorio (UUID versión 4) — se usa para crear un correlation_id nuevo cuando el cliente no mandó uno. | Sección 3 |
upstream | Nombre que se le da, en un proxy, al servicio "de arriba" al que se reenvía la petición (en este caso, users_service, products_service u orders_service). | Sección 3 |
@lru_cache | Decorador de functools (librería estándar de Python) que memoriza el resultado de una función: la primera vez que se llama, ejecuta el código y guarda el resultado; en las siguientes llamadas devuelve ese mismo resultado guardado, sin volver a ejecutar la función. | Sección 3 |
Autenticación con JWT
| Término | Qué es | Se profundiza en |
|---|---|---|
| JWT (JSON Web Token) | Un token con formato estándar que codifica datos (el "payload": quién es el usuario, su rol, cuándo vence) firmados digitalmente, para que el servidor pueda confiar en ellos sin tener que consultar una base de datos en cada request. | Sección 4 |
password_hash.hash() / .verify() | Funciones para hashear una contraseña al guardarla (nunca se guarda en texto plano) y para verificar si una contraseña ingresada coincide con el hash guardado — de la librería pwdlib (PasswordHash.recommended()). | Sección 4 |
create_access_token() | Función que arma el JWT: junta los datos del usuario en un payload, le pone fecha de expiración (exp) y lo firma con una clave secreta (jwt.encode). | Sección 4 |
| Bearer (token) | Un esquema de autenticación HTTP estándar (RFC 6750): "quien porta (bears) este token, tiene acceso" — no hace falta ninguna otra prueba de identidad más que mostrarlo. Se envía en el header Authorization: Bearer <token>. | Sección 4 |
HTTPBearer | Un esquema de seguridad de FastAPI que sabe leer el header Authorization: Bearer <token> y extraer el token — se usa como dependencia (Depends(security)). | Sección 4 |
get_current_user() | Una dependencia de FastAPI que decodifica y valida el JWT del request (jwt.decode), y devuelve los datos del usuario autenticado — cualquier endpoint que la use con Depends(...) queda protegido. | Sección 4 |
require_role(role) | Una dependencia parametrizada: función que devuelve otra dependencia, ya configurada para exigir un rol puntual (Depends(require_role("admin"))) — reusa get_current_user() por dentro y sirve para cualquier rol sin duplicar código. | Sección 4 |
payload (del JWT) | El contenido codificado dentro del token: acá incluye sub (email), uid (id del usuario), role, exp (expiración) e iat (issued at, cuándo se emitió). | Sección 4 |
Repository pattern (SQLAlchemy 2.0)
| Término | Qué es | Se profundiza en |
|---|---|---|
| Repository pattern | Una clase (UserRepository, OrderRepository) que concentra TODAS las consultas a la base de datos de una entidad — el resto del código (service.py, routers) le pide datos al repositorio en vez de escribir SQL/queries de SQLAlchemy por su cuenta. | Sección 4 |
select(Modelo) | Estilo moderno de SQLAlchemy 2.0 para armar una consulta (SELECT * FROM ...) como un objeto de Python, en vez de db.query(Modelo) (estilo 1.x, más viejo). | Sección 4 |
db.scalars(...).all() | Ejecuta el select(...) y devuelve todas las filas como una lista de objetos del modelo (no tuplas ni filas crudas). | Sección 4 |
db.scalar(...) | Igual que scalars, pero devuelve un solo resultado (o None si no hay ninguno) — para cuando se espera 0 o 1 fila, como buscar por email. | Sección 4 |
db.get(Modelo, id) | Atajo directo para buscar por clave primaria — más simple que armar un select(...).where(...) cuando ya se tiene el id. | Sección 4 |
Mapped[tipo] / mapped_column(...) | Sintaxis tipada de SQLAlchemy 2.0 para declarar una columna: Mapped[str] le dice al editor/type-checker qué tipo de Python es, y mapped_column(...) define las reglas reales de la columna (String(150), unique=True, nullable=False, default=...). | Sección 4 |
DeclarativeBase | La clase de la que heredan Base y, por lo tanto, todos los modelos (User, Order) — le da a SQLAlchemy el "mapa" para saber que esas clases Python representan tablas. | Sección 4 |
Schemas (Pydantic) para separar entrada/salida
| Término | Qué es | Se profundiza en |
|---|---|---|
Literal["user", "admin"] | Tipo de Python (de typing) que restringe un valor a un conjunto fijo de opciones exactas — a diferencia de str, que acepta cualquier texto, Literal rechaza cualquier rol que no sea uno de esos dos. | Sección 4 |
Field(min_length=..., max_length=...) | Agrega validaciones extra a un campo de un schema Pydantic (largo mínimo/máximo, etc.) más allá de solo el tipo. | Sección 4 |
EmailStr | Tipo de Pydantic que valida que el valor tenga formato de email válido (requiere el extra pydantic[email] / email-validator instalado). | Sección 4 |
ConfigDict(from_attributes=True) | Le dice al schema que puede construirse leyendo atributos de un objeto (como un modelo User de SQLAlchemy) y no solo de un diccionario — así UserResponse puede devolver directo un User de la base y Pydantic lo convierte solo. | Sección 4 |
| Schema de entrada vs. de salida | UserCreate/LoginRequest (lo que el cliente manda) y UserResponse/TokenResponse (lo que el servicio devuelve) son clases DISTINTAS a propósito — así nunca se devuelve por accidente un campo sensible (como password_hash) que sí existe en el modelo de base de datos. | Sección 4 |
🔌 2. Comunicación síncrona REST entre microservicios
Cuando orders_service necesita saber el precio o el stock de un producto, no tiene su propia copia de esa información — se la pregunta a products_service en el momento, por HTTP. Esto es comunicación síncrona: orders_service espera la respuesta antes de seguir (a diferencia de la comunicación asíncrona por eventos/colas, que se vería más adelante).
python
import httpx
from fastapi import HTTPException
from app.config import settings
async def get_product(product_id: int, correlation_id: str) -> dict:
url = f"{settings.products_url}/api/v1/products/{product_id}"
try:
async with httpx.AsyncClient(timeout=settings.http_timeout_seconds) as client:
response = await client.get(url, headers={"X-Correlation-ID": correlation_id})
except httpx.TimeoutException:
raise HTTPException(status_code=503, detail="Product Service excedió el tiempo de espera")
except httpx.RequestError:
raise HTTPException(status_code=503, detail="Product Service no disponible")
if response.status_code == 404:
raise HTTPException(status_code=404, detail="Producto no encontrado")
if response.status_code >= 500:
raise HTTPException(status_code=503, detail="Product Service presenta problemas")
if response.status_code >= 400:
raise HTTPException(status_code=502, detail="Respuesta inválida de Product Service")
try:
return response.json()
except ValueError:
raise HTTPException(status_code=502, detail="Product Service devolvió una respuesta inválida")Cómo traduce cada falla del servicio remoto en una respuesta propia:
Qué pasó en products_service | Excepción/condición que lo detecta | Código que devuelve orders_service |
|---|---|---|
| No respondió a tiempo | httpx.TimeoutException | 503 — "excedió el tiempo de espera" |
| Está caído / no hay red | httpx.RequestError | 503 — "no disponible" |
| El producto no existe | response.status_code == 404 | 404 — "Producto no encontrado" (se reenvía tal cual) |
Falla interna de products_service | status_code >= 500 | 503 — "presenta problemas" |
| Error de la petición (4xx que no es 404) | status_code >= 400 | 502 — "Respuesta inválida" |
| Devolvió algo que no es JSON válido | ValueError al hacer .json() | 502 — "respuesta inválida" |
💡 Fijate el patrón: casi todo lo que sale mal en el servicio remoto se convierte en
502/503(errores de "puerta de enlace"/"servicio no disponible"), no en un500genérico. Así, quien llama aorders_servicesabe que el problema no fue culpa deorders_servicesino de un servicio del que depende — la única excepción es el404, que sí tiene sentido reenviar tal cual (el producto realmente no existe).
🧪 Tip de entrevista: ¿por qué usar
async with httpx.AsyncClient(...)en vez de crear un cliente HTTP global? Porque abrir y cerrar el cliente por request evita fugas de conexiones abiertas; en apps con mucho tráfico se suele reusar un cliente a nivel de aplicación, pero para un llamado puntual como este,async withes simple y seguro.
⚠️ El header se llama
X-Correlation-ID(con X- al inicio) — es la convención histórica para headers HTTP no estándar/custom. Este mismocorrelation_ides el queOrderService.create()recibe como parámetro y guarda en el pedido (ver PARTE PRÁCTICA más abajo) — así un solo ID conecta el log del gateway, el deorders_servicey el deproducts_servicepara ese pedido puntual.
🚪 3. API Gateway Pattern
El API Gateway es un microservicio más (api_gateway), pero con una función especial: es el único punto de entrada de la API. El cliente externo (Postman, un frontend, etc.) le habla siempre al gateway — nunca directo a users_service, products_service u orders_service — y el gateway reenvía cada petición al servicio real.
🗺️ Diagrama: arquitectura del API Gateway
💡 Fuente editable en
04-Recursos/diagramas/clase-07-api-gateway-arquitectura.html.
📌 El gateway no tiene lógica de negocio, es un traductor. No valida stock, no calcula precios, no decide si un usuario puede o no hacer algo — eso es trabajo de cada microservicio. Lo único que hace es recibir, reenviar y devolver: traduce "una petición que llegó de afuera" en "una petición al servicio interno correcto", y la respuesta de ese servicio de vuelta al cliente externo. Por eso
proxy()no tiene ni un soloifde negocio — solo headers, URL y reenvío.
⚠️ Ojo — esto no contradice lo anterior: validar que el JWT sea válido (firma correcta, no vencido) SÍ es responsabilidad del gateway — es un chequeo transversal de seguridad, no una regla de negocio (ver Entrevistas → pregunta 2). El código de
proxy()que se ve más abajo solo reenvía el headerAuthorizationtal cual llegó — con el código completo deusers_service/app/security.pyyorders_service/app/security.pyya visto (sección 4 y sección 3.8), queda confirmado que el gateway nunca decodifica el JWT: cada microservicio lo valida por su cuenta conget_current_user(), usando el mismoJWT_SECRETcompartido.
⚙️ api_gateway/app/config.py
python
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "OrderFlow API Gateway"
app_version: str = "1.0.0"
users_url: str
products_url: str
orders_url: str
http_timeout_seconds: float | int = 3.0
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()Mismo patrón base que users_service/products_service en la Clase 6 (BaseSettings + SettingsConfigDict leyendo un .env), con dos diferencias nuevas:
users_url,products_url,orders_urlno tienen valor por defecto — son obligatorios. Si el.envno los define,pydantic-settingsfalla al arrancar en vez de dejar el gateway andando con URLs vacías (fail-fast: mejor un error claro al iniciar que un503confuso en el primer request).extra="ignore": si el.envtrae variables queSettingsno declara, no rompe — simplemente las ignora (útil cuando varios servicios comparten un mismo.envde ejemplo con más variables de las que cada uno usa).
💡
get_settings()con@lru_cacheen vez de solosettings = Settings(): la primera vez que algo pideget_settings(), se lee y valida el.envUNA sola vez; las llamadas siguientes devuelven ese mismo objeto ya armado, sin releer el archivo — un patrón común cuandoSettingsse va a usar además como dependencia de FastAPI (Depends(get_settings)), no solo importado directo como acá.
api_gateway/.env.example (el .env real de cada quien no se versiona — este archivo .example es la plantilla que sí se sube al repo):
bash
APP_NAME=OrderFlow API Gateway
APP_VERSION=1.0.0
USERS_URL=http://127.0.0.1:8001
PRODUCTS_URL=http://127.0.0.1:8002
ORDERS_URL=http://127.0.0.1:8004
HTTP_TIMEOUT_SECONDS=3.0💡 ¿Por qué variables de entorno y no escribir estos valores directo en el código? Varias razones, todas relevantes acá:
- Cambian según dónde corre el servicio, sin tocar código. En tu máquina
orders_servicecorre enhttp://127.0.0.1:8004; en producción sería otra URL completamente distinta (otro dominio, otro puerto, quizás HTTPS). Si esas URLs estuvieran hardcodeadas enmain.py, cambiar de entorno significaría editar y volver a desplegar código — con.enves solo cambiar un archivo de configuración.- Separan configuración de secretos del código fuente. Un
.envreal (con contraseñas, claves de API, tokens) nunca se sube al repositorio — por eso existe el patrón.env.example(plantilla sin datos sensibles, sí versionada) +.envreal (con los valores de verdad, en.gitignore). Así cualquiera que clona el proyecto sabe QUÉ variables necesita, sin ver los valores reales de nadie.- Un microservicio no debería "saber" en qué puerto corren los demás más que a través de su propia configuración — es la misma idea de desacoplamiento que ya vimos con
products_client.py(sección 2):api_gatewayno tiene8001/8002/8004escritos en su lógica, los recibe de afuera.Settingssin default enusers_url/products_url/orders_url(visto arriba) hace que, si a alguien se le olvida crear su.env, el servicio no arranque con una URL vacía silenciosa — falla inmediato con un error claro. Las variables de entorno + validación de Pydantic trabajan juntas para eso.
python
from uuid import uuid4
import httpx
from fastapi import FastAPI, HTTPException, Request, Response
from app.config import settings
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description=(
"Gateway didáctico de OrderFlow. Enruta solicitudes, propaga Authorization y "
"X-Correlation-ID y transforma fallas de red en 503. No contiene lógica de negocio."
),
)
@app.middleware("http")
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid4())
request.state.correlation_id = correlation_id
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response
@app.get(path="/health", tags=["Health"])
def health():
return {"status": "ok", "service": settings.app_name}
async def proxy(request: Request, base_url: str, upstream_path: str) -> Response:
headers = {"X-Correlation-ID": request.state.correlation_id}
if authorization := request.headers.get("Authorization"):
headers["Authorization"] = authorization
if content_type := request.headers.get("Content-Type"):
headers["Content-Type"] = content_type
url = f"{base_url}{upstream_path}"
body = await request.body()
try:
async with httpx.AsyncClient(timeout=settings.http_timeout_seconds) as client:
upstream = await client.request(
request.method,
url,
params=request.query_params,
content=body or None,
headers=headers,
)
except httpx.RequestError:
raise HTTPException(status_code=503, detail="Servicio upstream no disponible")
response_headers = {
"X-Correlation-ID": upstream.headers.get("X-Correlation-ID", request.state.correlation_id)
}
if "content-type" in upstream.headers:
response_headers["content-type"] = upstream.headers["content-type"]
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=response_headers,
)
# /api/v1/users
# Ruta "base" de usuarios: sin id al final. Cubre listar (GET, todos los usuarios) y
# crear (POST, un usuario nuevo) — las dos únicas acciones que no necesitan un id puntual.
@app.api_route(path="/api/v1/users", methods=["GET", "POST"], tags=["Proxy Users"])
async def users_base(request: Request):
return await proxy(request, settings.users_url, upstream_path="/api/v1/users")
# /api/v1/users/{path:path}
# Ruta "catch-all" de usuarios: cualquier cosa DESPUÉS de /users (un id, /users/5, o
# incluso sub-rutas con más "/"). Cubre ver uno (GET), actualizar (PATCH) y borrar
# (DELETE) un usuario puntual, sin declarar un endpoint por cada acción.
@app.api_route(
path="/api/v1/users/{path:path}",
methods=["GET", "POST", "PATCH", "DELETE"],
tags=["Proxy Users"],
)
async def users_proxy(path: str, request: Request):
return await proxy(request, settings.users_url, upstream_path=f"/api/v1/users/{path}")
# /api/v1/auth/{path:path}
# Login, generación de tokens, etc. Auth NO es un microservicio aparte: vive DENTRO de
# users_service, por eso reenvía a settings.users_url igual que las rutas de arriba.
@app.api_route(
path="/api/v1/auth/{path:path}",
methods=["GET", "POST"],
tags=["Proxy Auth"],
)
async def auth_proxy(path: str, request: Request):
return await proxy(request, settings.users_url, upstream_path=f"/api/v1/auth/{path}")
# /api/v1/products
# Igual que /api/v1/users pero para productos: listar (GET) y crear (POST) — reenvía
# a products_service.
@app.api_route(path="/api/v1/products", methods=["GET", "POST"], tags=["Proxy Products"])
async def products_base(request: Request):
return await proxy(request, settings.products_url, upstream_path="/api/v1/products")
# /api/v1/products/{path:path}
# Ver, actualizar o borrar UN producto puntual (por id, o cualquier sub-ruta) — reenvía
# a products_service.
@app.api_route(
path="/api/v1/products/{path:path}",
methods=["GET", "POST", "PATCH", "DELETE"],
tags=["Proxy Products"],
)
async def products_proxy(path: str, request: Request):
return await proxy(request, settings.products_url, upstream_path=f"/api/v1/products/{path}")
# /api/v1/orders
# Igual patrón, para pedidos: listar (GET) y crear un pedido nuevo (POST) — reenvía a
# orders_service, el microservicio nuevo de esta clase.
@app.api_route(path="/api/v1/orders", methods=["GET", "POST"], tags=["Proxy Orders"])
async def orders_base(request: Request):
return await proxy(request, settings.orders_url, upstream_path="/api/v1/orders")
# /api/v1/orders/{path:path}
# Ver, actualizar o borrar UN pedido puntual (por id, o cualquier sub-ruta) — reenvía
# a orders_service.
@app.api_route(
path="/api/v1/orders/{path:path}",
methods=["GET", "POST", "PATCH", "DELETE"],
tags=["Proxy Orders"],
)
async def orders_proxy(path: str, request: Request):
return await proxy(request, settings.orders_url, upstream_path=f"/api/v1/orders/{path}")Las 8 rutas de arriba se agrupan en 3 bloques, uno por microservicio (más /auth, que vive dentro de users_service) — el código ya se mostró completo arriba, así que acá no se repite, solo se lee agrupado por dueño:
👤 Rutas hacia users_service
users_base, users_proxy y auth_proxy (arriba) — 3 rutas, las 3 reenvían a settings.users_url — auth incluida, porque login no es un microservicio aparte: vive dentro de users_service (app/routers/auth.py, sección 3.6 más abajo). Es el microservicio de la Clase 6, con PATCH/DELETE agregados en esta clase (ver 🧱 El orden para agregar un endpoint nuevo).
📦 Rutas hacia products_service
products_base y products_proxy (arriba) — 2 rutas, reenvían a settings.products_url. En la Clase 6 products_service era una versión sin base de datos — en esta clase se armó de cero con Postgres propio, modelos, repositorio, servicio y router (código completo en 🗂️ 10. products_service).
🧾 Rutas hacia orders_service
orders_base y orders_proxy (arriba) — 2 rutas, reenvían a settings.orders_url. orders_service es el microservicio nuevo de esta clase (no existía en la Clase 6) — es el que consume a products_service por REST (sección 2) para armar cada pedido (código completo en 🧱 9. orders_service completo).
El "mapa de rutas" completo del gateway — nótese que todas repiten el mismo par (ruta base sin id + ruta con {path:path}), y que auth reenvía a users_service (no tiene microservicio propio: vive adentro de users_service):
| Ruta pública del gateway | Reenvía a (base_url) | Tag |
|---|---|---|
/api/v1/users, /api/v1/users/{path:path} | settings.users_url | Proxy Users |
/api/v1/auth/{path:path} | settings.users_url | Proxy Auth |
/api/v1/products, /api/v1/products/{path:path} | settings.products_url | Proxy Products |
/api/v1/orders, /api/v1/orders/{path:path} | settings.orders_url | Proxy Orders |
⚠️ ¿Dónde se define el
/api/v1? No es un prefijo separado ni una variable de configuración — está escrito a mano, repetido en cada una de las 8 rutas, directo en elpath=de cada@app.api_route(...)(arriba). No hay un solo lugar que lo centralice: si el día de mañana cambiara la versión de la API (/api/v2), habría que tocar las 8 rutas una por una. Por eso un typo tan chico como pedir/api/auth/loginen vez de/api/v1/auth/loginda404directo del gateway — la ruta simplemente no existe registrada,proxy()ni se llega a ejecutar. Caso real en 06-Errores.
Las dos piezas clave, explicadas por separado:
🔁 correlation_id_middleware — trazabilidad automática
Un middleware corre para absolutamente todas las peticiones, antes de que lleguen al endpoint. Este en particular:
- Busca si el request YA trae un
X-Correlation-ID(por ejemplo, si viene de otro sistema que ya generaba uno); si no, genera uno nuevo conuuid4(). - Lo guarda en
request.state.correlation_id— así cualquier función que reciba eserequest(comoproxy()) puede leerlo, sin tener que pasarlo a mano por cada parámetro. - Deja seguir la petición con
await call_next(request). - Antes de devolver la respuesta, le agrega el mismo
X-Correlation-IDcomo header — así quien llamó al gateway también puede ver (y loguear) ese ID.
💡 Este es el mecanismo real detrás de la "Trazabilidad básica" del temario: el gateway es quien origina el
correlation_idsi nadie lo mandó antes, y de ahí en más viaja por todos los servicios (gateway →orders_service→products_service) tal como se ve en la sección 2.
📝 Actualización:
users_service/app/main.pytiene este mismocorrelation_id_middleware, copiado tal cual, no solo el gateway. Tiene sentido: si algo llama ausers_servicedirecto (sin pasar por el gateway — por ejemplo, en un test), igual necesita uncorrelation_idpara sus logs. El patrón real es "cualquier servicio genera uno si no le llegó ninguno", y en el camino normal (cliente → gateway → microservicio) es el gateway quien lo origina primero — pero no es una exclusividad suya, es defensivo en cada capa.
🔀 proxy() — el reenvío genérico
Es una única función que sabe reenviar CUALQUIER petición a CUALQUIER servicio — por eso recibe base_url y upstream_path como parámetros en vez de tener la URL escrita fija. Se reutiliza tal cual para los tres microservicios (users_service, products_service, orders_service) y para auth — son 8 rutas que comparten una sola implementación. Hace tres cosas:
- Propaga los headers que importan:
Authorization(para que la seguridad JWT llegue al servicio real) yContent-Type, además delX-Correlation-ID. - Reenvía el método, la query, el body y espera la respuesta con
httpx.AsyncClient— mismo patrón de manejo de errores que ya vimos enproducts_client.py(sección 2): si el servicio de destino no responde, el gateway devuelve503. - Devuelve la respuesta del servicio real tal cual (contenido, código de estado, headers relevantes) — el cliente externo ni se entera de que hubo un salto en el medio.
Las dos rutas de abajo (/api/v1/users y /api/v1/users/{path:path}) son las que registran el patrón: una para la ruta base (GET/POST sin id) y otra con {path:path} — un "path converter" de FastAPI que captura cualquier resto de ruta (incluidos más /), para no tener que declarar una ruta distinta por cada endpoint de users_service.
🧪 Tip de entrevista: ¿cuál es la ventaja de un API Gateway frente a que el cliente le hable directo a cada microservicio? El cliente solo necesita conocer una URL (la del gateway); los microservicios pueden moverse, cambiar de puerto o escalar sin que el cliente se entere; y hay un solo lugar para centralizar cosas transversales (auth, trazabilidad, límites de tasa) en vez de repetirlas en cada servicio.
⚠️ Acotación real del profe, probando en Postman: probar todo pegándole directo a
users_service:8001confirma queusers_servicefunciona — pero no prueba el gateway en sí (en producción,users_servicenunca queda expuesto al mundo; solo el gateway es público). Paso a paso de los dos caminos, comparados lado a lado, más abajo en la sección 4, en "🆚 Probar directo vs. a través del gateway".
✅ Los imports de
api_gateway/app/main.pyya se confirmaron con la captura completa — coinciden exactamente con lo que se había inferido por cómo se usaba cada nombre en el código:pythonfrom uuid import uuid4 import httpx from fastapi import FastAPI, HTTPException, Request, Response from app.config import settings
🔐 4. Autenticación y autorización con JWT
🧩 Anatomía de un JWT
Un JWT no es un dato cifrado y opaco — es tres partes separadas por puntos (header.payload.signature), cada una codificada en Base64URL (se puede decodificar con cualquier herramienta, no hace falta la clave secreta para leerlo — solo para verificarlo):
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqdWFuQG1haWwuY29tIn0.4f2f8c1a...
└──────── HEADER ────────┘└──────── PAYLOAD ────────┘└─ SIGNATURE ─┘| Parte | Qué contiene | Ejemplo (genérico, tipo jwt.io) |
|---|---|---|
| Header | El algoritmo de firma usado y el tipo de token | {"alg": "HS256", "typ": "JWT"} |
| Payload | Los datos ("claims") — lo que el emisor quiere afirmar sobre el usuario | {"name": "Richard Hendricks", "exp": 1701887123, "sub": "0000-...-1111", "admin": true} |
| Signature | Firma criptográfica de header+payload con la clave secreta del servidor — la única parte que garantiza integridad | (bytes ilegibles, no es texto) |
⚠️ El payload NO está cifrado, solo codificado — cualquiera puede decodificarlo y leerlo (por eso nunca va una contraseña ni un dato sensible ahí adentro). Lo que la
signaturegarantiza es que nadie lo alteró después de que el servidor lo firmó: si alguien cambia un solo carácter del payload (p. ej."admin": true), la firma deja de coincidir yjwt.decode()lo rechaza.
Cómo se calcula la firma, en concreto (para el algoritmo HS256, el que usa el proyecto — jwt_algorithm en config.py):
signature = Base64URLSafe(
HMACSHA256(header + "." + payload, secret_key)
)Es decir: se toma el header y el payload (ya codificados en Base64URL y unidos con un punto), se les aplica HMAC-SHA256 usando la clave secreta del servidor (settings.jwt_secret), y el resultado se vuelve a codificar en Base64URL. Por eso nadie sin la clave secreta puede generar una firma válida — ni siquiera sabiendo exactamente qué payload quiere falsificar.
📌
HS256= HMAC con SHA-256, un algoritmo simétrico: la misma clave (jwt_secret) sirve para firmar Y para verificar. Existe tambiénRS256(asimétrico, con clave pública/privada) — se usa cuando quien verifica el token no debe poder firmar tokens nuevos (por ejemplo, si varios servicios verifican pero solo uno emite). Este proyecto usaHS256porque es un solo emisor (users_service) y no hace falta esa separación.
En el proyecto, create_access_token() (más abajo) arma su propio payload — distinto al del ejemplo genérico de arriba, pero mismo concepto—: usa sub para el email, uid para el id, role para el rol, exp/iat para expiración y emisión.
🎫 ¿Qué significa "Bearer"?
HTTPBearer no es un nombre arbitrario — Bearer es un esquema de autenticación HTTP estándar (definido en el RFC 6750). La palabra en inglés significa "portador": la idea es literalmente "quien porta este token, tiene acceso" — el servidor no vuelve a pedir usuario/contraseña ni ninguna otra prueba, con mostrar el token alcanza. Por eso el header se ve así:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqdWFuQG1haWwuY29tIn0.4f2f8c1a...⚠️ La otra cara de "portador": si alguien roba un Bearer token válido (por ejemplo interceptando una petición sin HTTPS, o leyéndolo de un log), puede usarlo como si fuera el dueño legítimo — el servidor no tiene forma de distinguir "es Juan" de "es alguien con el token de Juan". Por eso importan tanto el
expcorto (sección anterior) y viajar siempre por HTTPS en producción.
🧪 Tip de entrevista: ¿en qué se diferencia "Bearer" de otros esquemas de
Authorization, como "Basic"?Basicmanda usuario y contraseña (codificados, no cifrados) en cada request.Bearermanda un token que ya probó la identidad una vez (en el login) — no hace falta repetir la contraseña en cada llamada, pero a cambio el token en sí se vuelve el secreto a proteger.
🔄 El flujo completo, de punta a punta
User Client App Server
│ │ │
│ credenciales │ │
├───────────────▶│ 1) Login Request │
│ ├───────────────────────────────▶│
│ │ │ 2) Genera el JWT
│ │ │ con la clave secreta
│ │ 3) Returns JWT │
│ │◀───────────────────────────────┤
│ │ │
│ │ 4) Requests siguientes, │
│ │ con el JWT en el header │
│ ├───────────────────────────────▶│Estos 4 pasos son exactamente lo que ya está implementado en el proyecto:
| Paso del diagrama | Dónde está en el código |
|---|---|
| 1) Login Request | Cliente manda POST /api/v1/auth/login con {email, password} — llega al gateway, que lo reenvía a users_service (sección 3) |
| 2) Genera el JWT con la clave secreta | create_access_token() en security.py, firmando con settings.jwt_secret |
| 3) Returns JWT | login() en auth.py devuelve TokenResponse(access_token=token) |
| 4) Requests con el JWT | El cliente manda Authorization: Bearer <token> en cada llamada siguiente; get_current_user()/require_role() lo validan en cada endpoint protegido |
💡 Nótese que el paso 1 (login) es el único que viaja con la contraseña — a partir del paso 4, la identidad del usuario "vive" en el token, no se vuelve a mandar la contraseña nunca más hasta que el token expire y haya que loguearse de nuevo.
users_service es quien tiene la sección de auth (login) y es dueño de la lógica de JWT — por eso el gateway reenvía /api/v1/auth/* ahí mismo (sección 3). El flujo completo, en código:
python
router = APIRouter(prefix="/auth", tags=["Auth"])
Db = Annotated[Session, Depends(get_db)]
repo = UserRepository()
@router.post(path="/login", response_model=TokenResponse)
def login(data: LoginRequest, db: Db):
user = repo.get_by_email(db, str(data.email).lower())
if not user or not user.active or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciales inválidas")
token = create_access_token(user_id=user.id, email=user.email, role=user.role)
return TokenResponse(access_token=token)
@router.get("/me")
def me(current: dict = Depends(get_current_user)):
return currentPaso a paso del login:
- Busca al usuario por email (normalizado a minúsculas con
.lower()— asíJuan@Mail.comyjuan@mail.comson el mismo usuario). - Rechaza con
401si el usuario no existe, está inactivo (user.active), o la contraseña no matchea el hash guardado (verify_password) — un solo mensaje genérico ("Credenciales inválidas") para los tres casos, a propósito: así un atacante no puede distinguir "el email no existe" de "la contraseña está mal". - Si todo OK,
create_access_token()arma y firma el JWT. - Lo devuelve envuelto en
TokenResponse— el cliente lo guarda y lo manda en cada request futuro como headerAuthorization: Bearer <token>.
security.py — donde vive la lógica de hashear/verificar contraseñas y crear/validar el token:
python
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pwdlib import PasswordHash
from app.config import settings
password_hash = PasswordHash.recommended()
security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
def create_access_token(*, user_id: int, email: str, role: str) -> str:
expires = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_minutes)
payload = {
"sub": email,
"uid": user_id,
"role": role,
"exp": expires,
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> dict:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Autenticación requerida",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
return {
"id": int(payload["uid"]),
"email": payload["sub"],
"role": payload["role"],
}
except (jwt.InvalidTokenError, KeyError, TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido o expirado",
headers={"WWW-Authenticate": "Bearer"},
)
def require_role(role: str):
def dependency(user: dict = Depends(get_current_user)) -> dict:
if user["role"] != role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permiso insuficiente",
)
return user
return dependencyget_current_user() completo: decodifica el JWT y arma un dict "liviano" (id, email, role) leyendo del payload — no vuelve a consultar la base de datos. El except atrapa CUATRO tipos de error de una: jwt.InvalidTokenError (la clase base de PyJWT — cubre firma inválida, token vencido, formato roto, todo junto) más KeyError, TypeError, ValueError (por si el payload no tuviera alguna de esas claves, o uid no fuera convertible a int). Todos terminan en el mismo 401.
require_role() — exactamente el patrón que se había anticipado: envuelve a get_current_user() con Depends(...) (primero confirma identidad), y si el rol no coincide, corta con 403 — la línea entre autenticación (¿sos vos?) y autorización (¿podés hacer esto?) queda clarísima en el código: 401 antes de saber quién sos, 403 sabiendo quién sos pero sin permiso.
hash_password / verify_password — por qué nunca se guarda la contraseña tal cual:password_hash = PasswordHash.recommended() es de pwdlib, una librería moderna para hashear contraseñas (sucesora recomendada de passlib, que dejó de mantenerse activamente). .recommended() elige el algoritmo más seguro disponible (Argon2 por defecto) — un algoritmo de hash lento a propósito y con "sal" (salt) automática, para que ni siquiera dos usuarios con la misma contraseña tengan el mismo hash guardado, y para que probar contraseñas por fuerza bruta contra la base de datos sea carísimo.
📝 Corrección: se había inferido
passlib.context.CryptContext(una librería más vieja, pero con la misma idea) antes de ver esta captura — la librería real del proyecto espwdlib. Ya está corregido en el código del ejercicio (02-Ejercicios/Clase-07).
🧪 Tip de entrevista: ¿por qué el JWT lleva
exp(expiración) si ya está firmado y no se puede alterar? Porque firmado ≠ revocable: si alguien roba un token válido, sinexpserviría para siempre. La expiración acota la ventana de daño — por esoaccess_token_minutesexiste como configuración, no como un valor fijo en el código.
💡
get_current_user()comoDepends(...)es la pieza que faltaba de la sección 3: el gateway solo reenvía el headerAuthorization(no lo valida); acá, enusers_service, es donde el JWT realmente se decodifica y se verifica. Cualquier endpoint que quiera exigir login le agregaDepends(get_current_user)como parámetro — igual patrón que usaOrderService.get_authorized()para chequearuser["role"](sección 2 de la parte práctica).
users_service/app/repository.py — de dónde sale user en auth.py (repo.get_by_email(...)):
python
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import User
class UserRepository:
def get_all(self, db: Session) -> list[User]:
return list(db.scalars(select(User).order_by(User.id)).all())
def get_by_id(self, db: Session, user_id: int) -> User | None:
return db.get(User, user_id)
def get_by_email(self, db: Session, email: str) -> User | None:
return db.scalar(select(User).where(User.email == email))
def create(self, db: Session, user: User) -> User:
db.add(user)
db.commit()
db.refresh(user)
return userMismo Repository pattern que ya vimos en orders_service (OrderRepository, sección 2 de la parte práctica): el login de auth.py no sabe nada de SQL — solo le pide repo.get_by_email(db, email) y confía en que le devuelva el User o None.
💡
get_by_id/get_by_emaildevuelvenUser | None, nunca lanzan una excepción si no encuentran nada — es la propia función que llama (login, enauth.py) la que decide qué hacer con eseNone(acá, devolver401). El repository solo consulta, nunca decide qué responder — esa es la línea entrerepository.py(acceso a datos) yservice.py/routers (reglas de negocio).
🧪 Tip de entrevista: ¿por qué
create()hacedb.commit()Ydb.refresh(user)?commit()guarda los cambios de verdad en la base;refresh()vuelve a leer esa fila desde la base hacia el objeto Python, para traer valores que la base generó sola (como unidautoincremental) y que el objeto en memoria todavía no tenía.
users_service/app/config.py — mismo patrón exacto que api_gateway (@lru_cache + get_settings()), con los campos que security.py necesita:
python
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Users Service"
app_version: str = "1.0.0"
database_url: str
jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_minutes: int = 30
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()💡 Nótese que
database_urlyjwt_secretno tienen valor por defecto — son obligatorios, mismo criterio de fail-fast que ya vimos enapi_gateway/config.py(sección 3): sin esos dos datos, el servicio ni siquiera puede levantar (no hay a qué base conectarse, ni con qué clave firmar tokens).
users_service/app/routers/users.py — el CRUD de usuarios, con roles:
python
router = APIRouter(prefix="/users", tags=["Users"])
Db = Annotated[Session, Depends(get_db)]
service = UserService()
repo = UserRepository()
@router.post(path="", response_model=UserResponse, status_code=201)
def register_user(data: UserCreate, db: Db):
return service.register(db, data)
@router.get(path="", response_model=list[UserResponse])
def list_users(db: Db, _admin: dict = Depends(require_role("admin"))):
return repo.get_all(db)
@router.get(path="/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: Db, current: dict = Depends(require_role("admin"))):
return service.get_or_404(db, user_id)Roles y permisos, en acción:
register_user(POST /users) no tieneDepends(require_role(...))— cualquiera puede registrarse, tiene sentido (nadie tiene un token todavía antes de existir).list_usersyget_usersí lo tienen: solo un admin puede listar todos los usuarios o consultar uno puntual por id.require_role("admin")es una dependencia parametrizada:require_roleno es la dependencia en sí, es una función que devuelve una (por eso se llama con paréntesis,require_role("admin"), dentro deDepends(...)) — el mismo rol podría reusarse para pedir otro rol distinto,require_role("editor"), sin duplicar código.
✅ La implementación de
require_role()ya se confirmó — está completa ensecurity.py(sección 4 más arriba) y hace justo lo que se había anticipado: envuelve aget_current_user()y agregaif user["role"] != role: raise HTTPException(403, ...), mismo patrón queOrderService.get_authorized()enorders_service.
users_service/app/db.py — idéntico patrón a la Clase 4 (db/database.py):
python
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()users_service/app/models.py — el modelo User:
python
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[str] = mapped_column(String(150), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(30), default="user", nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)💡 Notá que la columna se llama
password_hash, nuncapassword— refuerza lo desecurity.py: lo único que se guarda es el hash, la contraseña real jamás toca la base de datos.unique=True(la base rechaza un duplicado aunque el código se olvide de chequearlo) eindex=True(para queget_by_email()sea rápido, ya que se usa en cada login).
users_service/app/main.py — arma la app y monta los dos routers:
python
from uuid import uuid4
from fastapi import FastAPI, Request
from app.config import settings
from app.routers.auth import router as auth_router
from app.routers.users import router as users_router
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="Microservicio de identidad y usuarios de OrderFlow.",
)
@app.middleware("http")
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid4())
request.state.correlation_id = correlation_id
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response
@app.get(path="/health", tags=["Health"])
def health():
return {"status": "ok", "service": settings.app_name, "version": settings.app_version}
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_router, prefix="/api/v1")users_service/app/schemas.py — los contratos de entrada/salida de la API:
python
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
Role = Literal["user", "admin"]
class UserCreate(BaseModel):
name: str = Field(min_length=3, max_length=100)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class UserResponse(BaseModel):
id: int
name: str
email: EmailStr
role: Role
active: bool
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class CurrentUser(BaseModel):
id: int
email: EmailStr
role: Role💡
UserCreatepidepassword(texto plano, solo de entrada) peroUserResponseni siquiera tiene ese campo — es la separación entrada/salida en acción: el cliente manda la contraseña UNA vez para registrarse,UserService.register()(código completo más abajo, en esta misma sección) la hashea antes de guardar, y la API nunca devuelve ni el password ni su hash en ninguna respuesta futura.
🧪 Tip de entrevista: ¿por qué
UserResponserepite casi los mismos campos que el modeloUserdemodels.pyen vez de devolver el modelo directo? Porque un schema de salida es un contrato explícito de qué se expone — si mañanaUsergana un campo sensible nuevo (p. ej.failed_login_attempts), no se filtra solo porque quedó en el modelo: hay que agregarlo a mano aUserResponsepara que aparezca en la API.
users_service/app/service.py — la lógica de negocio del registro:
python
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models import User
from app.repository import UserRepository
from app.schemas import UserCreate
from app.security import hash_password
class UserService:
def __init__(self, repo: UserRepository | None = None):
self.repo = repo or UserRepository()
def register(self, db: Session, data: UserCreate) -> User:
email = str(data.email).lower()
if self.repo.get_by_email(db, email):
raise HTTPException(status_code=409, detail="El email ya está registrado")
user = User(
name=data.name.strip(),
email=email,
password_hash=hash_password(data.password),
role="user",
active=True,
)
try:
return self.repo.create(db, user)
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="El email ya está registrado")
def get_or_404(self, db: Session, user_id: int) -> User:
user = self.repo.get_by_id(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
return user💡
register()chequea el email duplicado DOS VECES — antes de insertar (get_by_email) Y con untry/except IntegrityErroralrededor delcreate. ¿Por qué no alcanza con el primer chequeo? Porque entre que se consulta y se inserta puede pasar una fracción de segundo en la que otro request registre ese mismo email primero (condición de carrera) — el chequeo previo es para el caso normal (mensaje de error más específico, más rápido), y elexcept IntegrityError(que salta por la restricciónunique=Truedemodels.py) es la red de seguridad real que la base de datos garantiza sin importar la carrera.db.rollback()deshace la transacción a medio hacer antes de devolver el error.
🧪 Tip de entrevista: ¿por qué todos los roles nuevos se crean con
role="user"fijo enregister(), en vez de dejar que el cliente mande el rol enUserCreate? Porque si el cliente pudiera elegir su propio rol, cualquiera podría registrarse como"admin"— el rol se asigna del lado del servidor, nunca confiando en lo que manda quien se está registrando.
🗺️ Diagrama: todo junto — crear un pedido con JWT, a través del gateway
💡 Fuente editable en
04-Recursos/diagramas/clase-07-api-gateway-secuencia-pedido.html.
Este diagrama junta las tres piezas grandes de la clase en un solo flujo: JWT (sección 4, el cliente ya trae un token de un login previo), API Gateway (sección 3, correlation_id_middleware + proxy()) y comunicación REST entre microservicios (sección 2, orders_service → products_service vía products_client.py) — el mismo X-Correlation-ID viaja de punta a punta, por los 3 saltos, para que un solo pedido se pueda rastrear en los logs de los 3 servicios.
📝
orders_servicesí tiene su propiosecurity.py(código completo en 9.orders_servicecompleto) — el paso "valida el token" de este diagrama no es solo el diseño esperado, es el mismo patrón real deusers_service(sección 4) ya verificado funcionando enorders_service.
🧱 El orden para agregar un endpoint nuevo (schema → repository → service → router)
users_service solo traía POST (registrar), GET (listar) y GET /{id} (ver uno) — faltaban actualizar (PATCH) y borrar (DELETE). Esto no es código del profe: es un agregado hecho a mano, siguiendo el mismo patrón que ya usa el resto del proyecto, para practicar cómo se suma un endpoint nuevo a una arquitectura en capas ya armada.
La idea de fondo: en esta arquitectura, cada capa depende de la de abajo — router.py usa service.py, service.py usa repository.py, y repository.py usa el modelo (models.py) y el schema (schemas.py). Armarlas de abajo hacia arriba (dato → acceso a datos → reglas de negocio → endpoint HTTP) evita ir y volver: cuando llegás a escribir el router, todo lo que necesita ya existe.
1) schema (schemas.py) "¿qué datos entran/salen?"
│ UserUpdate: qué campos se pueden actualizar, con qué validaciones
▼
2) repository (repository.py) "¿cómo se toca la base de datos?"
│ update()/delete(): el SQL/ORM puro, sin reglas de negocio
▼
3) service (service.py) "¿qué reglas de negocio aplican?"
│ update()/delete(): busca con get_or_404(), aplica los cambios, delega al repo
▼
4) router (routers/users.py) "¿qué ruta HTTP dispara esto, y quién puede llamarla?"
PATCH/DELETE /{user_id}, protegidos con require_role("admin")🗺️ Diagrama: el orden para agregar un endpoint nuevo
📎 Fuente editable en
04-Recursos/diagramas/clase-07-endpoint-nuevo-capas.html.
El diagrama dibuja el orden de construcción (paso 1 → 4, de arriba hacia abajo) — notá que es el sentido contrario al de la dependencia en tiempo de ejecución: el router llama al service, que llama al repository, que usa el schema como tipo de dato. Se arma en un orden y se ejecuta en el orden inverso; por eso el schema (el contrato de datos) es el punto de partida, y el router (la ruta HTTP, quien la puede llamar) es lo último que aparece.
1) schemas.py — el contrato de entrada primero:
python
class UserUpdate(BaseModel):
name: str | None = Field(default=None, min_length=3, max_length=100)
active: bool | None = NoneTodos los campos son opcionales (| None = None) — es un PATCH (actualización parcial), no un PUT (reemplazo completo): el cliente manda solo lo que quiere cambiar. A propósito no se puede tocar email, password ni role desde acá (ver callout más abajo).
2) repository.py — el acceso a datos, sin reglas:
python
def update(self, db: Session, user: User) -> User:
db.commit()
db.refresh(user)
return user
def delete(self, db: Session, user: User) -> None:
db.delete(user)
db.commit()Nótese que update() no recibe los campos nuevos — recibe el objeto User ya modificado en memoria (eso lo hace el service) y solo lo persiste. El repository no decide qué cambiar, solo cómo guardarlo.
3) service.py — acá sí van las reglas:
python
def update(self, db: Session, user_id: int, data: UserUpdate) -> User:
user = self.get_or_404(db, user_id)
if data.name is not None:
user.name = data.name.strip()
if data.active is not None:
user.active = data.active
return self.repo.update(db, user)
def delete(self, db: Session, user_id: int) -> None:
user = self.get_or_404(db, user_id)
self.repo.delete(db, user)Reusa get_or_404() (ya existía, de la sección de arriba) para no duplicar el chequeo de "¿existe?". El if data.X is not None es lo que hace el PATCH parcial de verdad: solo pisa el campo si el cliente lo mandó.
4) routers/users.py — recién acá aparece HTTP:
python
@router.patch(path="/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
data: UserUpdate,
db: Db,
current: dict = Depends(require_role("admin")),
):
return service.update(db, user_id, data)
@router.delete(path="/{user_id}", status_code=204)
def delete_user(
user_id: int,
db: Db,
current: dict = Depends(require_role("admin")),
):
service.delete(db, user_id)status_code=204 (No Content) en el DELETE es la convención REST: "salió bien, y no hay nada que devolver" — por eso delete_user no tiene return.
Verificado en terminal, de punta a punta:
$ curl -X PATCH .../api/v1/users/2 -d '{"name":"Test User Actualizado"}'
{"id":2,"name":"Test User Actualizado","email":"test@orderflow.dev","role":"user","active":true}
$ curl -X DELETE .../api/v1/users/2
HTTP/1.1 204 No Content
$ curl .../api/v1/users/2
HTTP/1.1 404 Not Found # confirma que se borró de verdad⚠️ Por qué
UserUpdateno deja tocarpassword/role: cada uno tiene su propio motivo para ir aparte —unique(cambiarlo mal podría romper el índice o robarle el email a otro),passworddebería exigir la contraseña actual antes de cambiarla (no lo hace ningún endpoint de este proyecto todavía), yrolenunca se cambia con un PATCH genérico — si un admin pudiera mandar{"role": "admin"}en el body de cualquier update, cualquiera con acceso de edición se auto-asciende. Un cambio de rol necesitaría su propio endpoint, más restringido.
🧪 Tip de entrevista: ¿por qué
PATCHy noPUTpara actualizar?PUTreemplaza el recurso completo (si no mandás un campo, se asume que lo estás borrando/poniendo en su default).PATCHes explícitamente parcial — solo cambia lo que viene en el body. Con un schema donde todo es opcional (| None = None),PATCHes la semántica correcta.
💻 PARTE PRÁCTICA
El proyecto de la Clase 6 (users_service + products_service) se amplía con dos piezas nuevas: un orders_service (el microservicio de pedidos, que consume a products_service por REST) y un api_gateway (punto de entrada único, tema de las próximas secciones).
📝 Qué se reutiliza de la Clase 6 y qué es nuevo: el esqueleto (
app/config.pyconpydantic_settings,app/main.pyarmando FastAPI +/health+include_router(prefix="/api/v1"),app/routers/con un router por recurso) es el mismo patrón que ya usabanusers_serviceyproducts_serviceen la Clase 6 — es el mismo proyecto progresivo, no uno nuevo desde cero. Lo que sí es nuevo de esta clase es todo lo que la 6 no tenía:db.py+models.py+repository.py(persistencia real con SQLAlchemy — en la 6 todo vivía en memoria),security.py(JWT/roles) yapp/clients/(llamadas REST entre microservicios). Por esoorders_servicese ve "más grande" que sus hermanos.
🗂️ 5. Estructura del proyecto (02-Ejercicios/Clase-07/project)
project/
├── api_gateway/ # puerta de entrada única — ver sección 3 (PARTE TEÓRICA)
│ ├── app/
│ │ ├── __init__.py
│ │ ├── config.py # Settings (pydantic-settings) + get_settings() con @lru_cache
│ │ └── main.py # correlation_id_middleware + proxy() ↓
│ ├── tests/
│ ├── .env.example
│ └── requirements.txt
├── orders_service/ # completo — tabla orders creada, ver sección práctica ↑
│ ├── app/
│ │ ├── clients/ # clientes REST hacia OTROS microservicios
│ │ │ ├── __init__.py
│ │ │ └── products_client.py # get_product(): llama a products_service por HTTP
│ │ ├── routers/
│ │ │ ├── __init__.py
│ │ │ └── orders.py # POST/GET /orders — ver sección práctica ↑
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── db.py
│ │ ├── main.py
│ │ ├── models.py # modelo Order (SQLAlchemy)
│ │ ├── repository.py # OrderRepository (acceso a datos)
│ │ ├── schemas.py # OrderCreate y demás (Pydantic)
│ │ ├── security.py # get_current_user/require_role, mismo JWT_SECRET que users
│ │ └── service.py # OrderService: la lógica de negocio ↓
│ ├── migrations/ # Alembic — tabla orders ya creada
│ ├── tests/
│ ├── .env.example
│ ├── alembic.ini
│ └── requirements.txt
├── products_service/ # completo — armado de cero en esta clase, tabla products creada
│ ├── app/
│ │ ├── routers/
│ │ │ ├── __init__.py
│ │ │ └── products.py # CRUD + deactivate() — ver sección práctica ↑
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── db.py
│ │ ├── main.py
│ │ ├── models.py # modelo Product (sku, price, stock, active)
│ │ ├── repository.py # ProductRepository
│ │ ├── schemas.py # ProductCreate/Update/Response
│ │ ├── security.py # mismo patrón que orders_service
│ │ └── service.py # ProductService (create/update/deactivate)
│ ├── migrations/ # Alembic — tabla products ya creada
│ ├── tests/
│ ├── .env.example
│ ├── alembic.ini
│ └── requirements.txt
├── users_service/ # ampliado en esta clase — mismo patrón que orders_service
│ ├── app/
│ │ ├── routers/
│ │ │ ├── __init__.py
│ │ │ ├── auth.py # login + /me — ver sección 4 (PARTE TEÓRICA)
│ │ │ └── users.py # CRUD + roles (require_role) — ver sección 4 ↑
│ │ ├── __init__.py
│ │ ├── config.py # Settings + get_settings() — ver sección 4 ↑
│ │ ├── db.py # engine + SessionLocal + get_db() — ver sección 4 ↑
│ │ ├── main.py # FastAPI + correlation_id_middleware — ver sección 4 ↑
│ │ ├── models.py # modelo User — ver sección 4 ↑
│ │ ├── repository.py # UserRepository — ver sección 4 (PARTE TEÓRICA)
│ │ ├── schemas.py # UserCreate/Response, LoginRequest, TokenResponse — sección 4 ↑
│ │ ├── security.py # JWT + hash de contraseñas — ver sección 4 ↑
│ │ └── service.py # UserService.register()/get_or_404() — sección 4 ↑
│ ├── migrations/ # Alembic — tabla users ya creada, ver sección 4 ↑
│ ├── scripts/
│ │ └── create_admin.py # siembra el primer admin — ver sección 4 ↑
│ ├── tests/
│ │ └── test_health.py # primer test del servicio — ver sección 4 ↑
│ ├── .env.example # DATABASE_URL con psycopg + orderflow_users_db — ↑
│ ├── alembic.ini
│ └── requirements.txt
├── sql/
├── .gitignore
├── README.md
└── requirements-all.txt💡 El patrón
app/clients/es la forma de organizar la comunicación síncrona REST entre microservicios: cada servicio externo que se consume tiene su propio archivo "cliente" (aquíproducts_client.py), en vez de llamar arequests/httpxsueltos desde cualquier parte del código.
📝 Todos los
__init__.pyvacíos que aparecen en el árbol de arriba (app/__init__.py,app/clients/__init__.py,app/routers/__init__.py) no son obligatorios desde Python 3.3 — una carpeta se puede importar como paquete sin ellos (namespace packages). Se siguen agregando igual porque sirven para reexportar símbolos o correr código al importar el paquete, aunque acá estén vacíos — ver "Namespace packages" en 00-Notas/02-Conceptos.md.
🚪 6. api_gateway/app/main.py — el gateway
El código completo (correlation_id_middleware, la función genérica proxy() y las rutas /api/v1/users) y su explicación están en PARTE TEÓRICA → sección 3 "API Gateway Pattern" — mismo criterio que con products_client.py, documentado una sola vez.
🧩 7. orders_service/app/service.py — OrderService
Es la capa de lógica de negocio del microservicio de pedidos: no habla directo con la base de datos (eso lo hace OrderRepository) ni con HTTP (eso lo hace products_client) — orquesta ambas cosas y aplica las reglas del negocio.
python
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.clients.products_client import get_product # cliente REST → products_service
from app.models import Order
from app.repository import OrderRepository
from app.schemas import OrderCreate
class OrderService:
def __init__(self, repo: OrderRepository | None = None):
# Inyección de dependencias: si no le pasan un repo, crea el real.
# Facilita testear con un repo falso/mock.
self.repo = repo or OrderRepository()
async def create(
self, db: Session, data: OrderCreate, user: dict, correlation_id: str
) -> Order:
# 1) Consumo de API entre servicios: le pregunta a products_service
# por el producto, propagando el correlation_id (trazabilidad distribuida).
product = await get_product(data.product_id, correlation_id)
# 2) Reglas de negocio + manejo de errores distribuidos:
# si products_service dice que no hay stock o está inactivo, se corta acá
# con un 409 (Conflict) — no se llega a crear el pedido.
if not product.get("active", True):
raise HTTPException(status_code=409, detail="Producto inactivo")
if int(product.get("stock", 0)) < data.quantity:
raise HTTPException(status_code=409, detail="Stock insuficiente")
# 3) Cálculo del total con Decimal (nunca float, para no perder precisión con dinero).
unit_price = Decimal(str(product["price"]))
total = unit_price * data.quantity
order = Order(
user_id=user["id"],
product_id=product["id"],
product_name=product["name"], # se copia el nombre (snapshot al momento del pedido)
unit_price=unit_price,
quantity=data.quantity,
total=total,
status="Creado",
correlation_id=correlation_id, # queda guardado para poder rastrear el pedido
)
return self.repo.create(db, order)
def get_authorized(self, db: Session, order_id: int, user: dict) -> Order:
order = self.repo.get_by_id(db, order_id)
if not order:
raise HTTPException(status_code=404, detail="Pedido no encontrado")
# Roles y permisos: un usuario normal solo puede ver SUS pedidos;
# un admin puede ver cualquiera.
if user["role"] != "admin" and order.user_id != user["id"]:
raise HTTPException(status_code=403, detail="No puede consultar este pedido")
return order🗺️ Diagrama: las reglas de negocio de create(), como flujo
💡 Fuente editable en
04-Recursos/diagramas/clase-07-orderservice-flujo-reglas.html.
Es el mismo código de arriba, pero como flujo de decisión — útil para tenerlo en mente al resolver los Ejercicios 9 y 10 (los que provocan cada 409 a propósito): la consulta a products_service pasa siempre primero, y de ahí salen dos caminos de corte (inactivo / sin stock) antes de llegar a guardar el pedido.
🧪 Tip de entrevista: ¿por qué
createpropaga uncorrelation_iden vez de dejar que cada servicio genere el suyo? Porque en una arquitectura de microservicios, un solo pedido del usuario dispara varias llamadas HTTP encadenadas (gateway → orders → products). Sin un identificador común, es imposible seguir ese pedido en los logs de cada servicio.
⚠️ Mientras se creaba
app/clients/products_client.py, el IDE marcabafrom app.clients.products_client import get_productcomoCannot find reference 'clients' in 'app'(el paqueteclients/todavía no existía). Se resolvió creandoapp/clients/__init__.pyyproducts_client.py— detalle completo en06-Errores.
🌐 8. orders_service/app/clients/products_client.py — el cliente REST
El código completo y su explicación (manejo de errores distribuidos, X-Correlation-ID, timeouts) están más arriba, en PARTE TEÓRICA → sección 2 "Comunicación síncrona REST entre microservicios" — es el mismo archivo, documentado ahí para no repetirlo dos veces.
🔐 users_service/app/routers/auth.py y app/security.py — login y JWT
El código completo (login, /me, hash_password/verify_password, create_access_token, get_current_user) y su explicación paso a paso están en PARTE TEÓRICA → sección 4 "Autenticación y autorización con JWT".
🗺️ Diagrama: los 3 microservicios y sus 3 bases de datos, separados
Antes del código — el mapa completo de cómo se relacionan users_service, products_service y orders_service con sus propias bases de datos, y entre sí:
💡 Fuente editable en
04-Recursos/diagramas/clase-07-database-per-service.html.
Cómo leerlo:
- Los 3 recuadros punteados son los 3 bounded contexts — cada uno con su microservicio arriba y su base de datos abajo, aislado de los otros dos. Ningún servicio abre una conexión a la base de datos de otro (sección 5, "Acoplamiento entre microservicios").
- Las flechas grises desde
api_gatewayson puro enrutamiento (proxy(), sección 3) — no tocan ninguna base de datos directo, solo reenvían al servicio dueño. - La única flecha azul entre microservicios (
orders_service → products_service,GET /products) es la excepción real al aislamiento:orders_servicenecesita saber precio y stock de un producto que él NO tiene guardado, y en vez de leer la base deproducts_servicedirecto, se lo pregunta por HTTP (products_client.py, sección 2) — el contrato es la API, nunca la base de datos ajena. orders_servicequeda resaltado (recuadro azul) porque es el único de los 3 que combina las dos cosas: tiene su propia base y depende de otro servicio para completar su lógica de negocio — es el nodo más "interesante" para entender el flujo completo de un pedido (ver también el diagrama de secuencia, sección 4).
🧱 9. orders_service completo — el resto de los archivos
📝 Fuente distinta a partir de acá: todo lo de esta sección y la siguiente (
products_service) no salió de capturas del IDE del profe — salió del repositorio oficial del curso (carpetaS07/project), que Styp compartió para no improvisar el resto. Es la misma fuente de verdad, solo que traída directo del repo en vez de transcripta de una captura — se marca la diferencia para que quede claro de dónde salió cada cosa.
Ya estaban confirmados por captura service.py y clients/products_client.py (secciones 2 y de arriba) — y coinciden exactos, carácter por carácter, con lo que hay en el repo. Acá van los que faltaban:
app/models.py — el modelo Order:
python
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(nullable=False, index=True)
product_id: Mapped[int] = mapped_column(nullable=False)
product_name: Mapped[str] = mapped_column(String(120), nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
quantity: Mapped[int] = mapped_column(nullable=False)
total: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="Creado", nullable=False)
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)💡
product_nameyunit_pricequedan copiados dentro del pedido (no son una FK aproducts_service— ni podrían serlo, es OTRA base de datos). Es a propósito: si mañana cambia el precio o el nombre del producto, los pedidos viejos siguen mostrando el precio/nombre que tenían al momento de comprar — es como funciona una boleta real.correlation_idqueda indexado porque es lo que se usa para rastrear un pedido en los logs de los 3 servicios (sección 4, diagrama de secuencia).
app/schemas.py:
python
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class OrderCreate(BaseModel):
product_id: int = Field(gt=0)
quantity: int = Field(gt=0)
class OrderResponse(BaseModel):
id: int
user_id: int
product_id: int
product_name: str
unit_price: Decimal
quantity: int
total: Decimal
status: str
correlation_id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)OrderCreate es mínimo a propósito: el cliente solo elige qué producto y cuánto — todo lo demás (user_id, unit_price, total, status, correlation_id) lo calcula/asigna el servidor (service.py), nunca se confía en que el cliente los mande.
app/repository.py:
python
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Order
class OrderRepository:
def create(self, db: Session, order: Order) -> Order:
db.add(order)
db.commit()
db.refresh(order)
return order
def get_by_id(self, db: Session, order_id: int) -> Order | None:
return db.get(Order, order_id)
def list_for_user(self, db: Session, user_id: int) -> list[Order]:
return list(db.scalars(select(Order).where(Order.user_id == user_id).order_by(Order.id.desc())).all())
def list_all(self, db: Session) -> list[Order]:
return list(db.scalars(select(Order).order_by(Order.id.desc())).all())app/security.py — mismo patrón exacto que users_service/app/security.py (get_current_user, require_role), pero sin hash_password/verify_password/ create_access_token: orders_service nunca emite tokens (eso es trabajo exclusivo de users_service), solo valida los que ya existen — para eso necesita el mismo JWT_SECRET/JWT_ALGORITHM que users_service, compartido entre los dos .env.
app/routers/orders.py:
python
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.db import get_db
from app.repository import OrderRepository
from app.schemas import OrderCreate, OrderResponse
from app.security import get_current_user
from app.service import OrderService
router = APIRouter(prefix="/orders", tags=["Orders"])
Db = Annotated[Session, Depends(get_db)]
service = OrderService()
repo = OrderRepository()
@router.post("", response_model=OrderResponse, status_code=201)
async def create_order(
data: OrderCreate,
request: Request,
db: Db,
user: dict = Depends(get_current_user),
):
return await service.create(db, data, user, request.state.correlation_id)
@router.get("", response_model=list[OrderResponse])
def list_orders(db: Db, user: dict = Depends(get_current_user)):
if user["role"] == "admin":
return repo.list_all(db)
return repo.list_for_user(db, user["id"])
@router.get("/{order_id}", response_model=OrderResponse)
def get_order(order_id: int, db: Db, user: dict = Depends(get_current_user)):
return service.get_authorized(db, order_id, user)💡
list_ordersfiltra según el rol, sin unrequire_roleque bloquee — un usuario normal puede llamar al endpoint (no le da403), pero solo ve sus propios pedidos (repo.list_for_user); un admin ve todos (repo.list_all). Es una variante de autorización distinta a la deusers_service: en vez de "podés entrar o no", acá es "todos entran, pero cada quien ve lo suyo" — mismo patrón queget_authorized()enservice.py(sección 2). Elrequest.state.correlation_idque recibecreate_orderes el mismo que dejócorrelation_id_middleware(idéntico al deusers_service/api_gateway) — así el pedido queda taggeado con el ID que originó el gateway.
Alembic: mismo patrón (alembic init + env.py editado a mano + migración con nombre secuencial 0001_orders) que users_service (sección 4) — la tabla orders queda creada en orderflow_orders_db.
🗂️ 10. products_service — el catálogo, armado de cero
Este microservicio no existía en la Clase 6 (esa versión no tenía base de datos) — era la última pieza que faltaba para que orders_service tuviera a quién consultarle precio y stock de verdad. Ya está completo: modelos, schemas, repositorio, servicio, router y migración de Alembic corriendo contra orderflow_products_db (código completo a continuación).
app/models.py:
python
from decimal import Decimal
from sqlalchemy import Boolean, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
stock: Mapped[int] = mapped_column(default=0, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)sku (Stock Keeping Unit — el código interno del producto) es unique — es el identificador "de negocio" del producto, distinto del id autoincremental.
app/schemas.py:
python
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ProductCreate(BaseModel):
sku: str = Field(min_length=3, max_length=30)
name: str = Field(min_length=3, max_length=120)
price: Decimal = Field(gt=0, decimal_places=2)
stock: int = Field(default=0, ge=0)
@field_validator("sku")
@classmethod
def normalize_sku(cls, value: str) -> str:
return value.strip().upper()
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
return value.strip()
class ProductUpdate(BaseModel):
name: str | None = Field(default=None, min_length=3, max_length=120)
price: Decimal | None = Field(default=None, gt=0, decimal_places=2)
stock: int | None = Field(default=None, ge=0)
class ProductResponse(ProductCreate):
id: int
active: bool
model_config = ConfigDict(from_attributes=True)💡
@field_validator(Pydantic) corre una función propia además de la validación automática de tipos — acá normaliza elskua mayúsculas ("tec-001"→"TEC-001") para que nunca existan dos SKUs iguales que difieran solo en mayúsculas/minúsculas.ProductResponse(ProductCreate)hereda deProductCreateen vez de repetir los 4 campos — patrón de Pydantic para no duplicar un schema que es "el mismo + algunos campos más" (acá,idyactive).
app/repository.py y app/service.py — mismo Repository pattern que ya se vio en users_service/orders_service, con una regla de negocio nueva: deactivate() en vez de un DELETE real —
python
def deactivate(self, db: Session, product_id: int) -> Product:
product = self.get_or_404(db, product_id)
if not product.active:
raise HTTPException(status_code=409, detail="El producto ya está inactivo")
product.active = False
return self.repo.update(db, product)🧪 Tip de entrevista: ¿por qué
products_servicedesactiva en vez de borrar (soft delete), mientras queusers_servicesí borra de verdad (DELETE, sección "El orden para agregar un endpoint nuevo")? Porque un producto puede estar referenciado por pedidos viejos (orders.product_id) — borrarlo de la tabla dejaría esos pedidos "rotos". Un usuario, en este proyecto, no tiene ninguna tabla que dependa de él, así que borrarlo de verdad es seguro.
app/routers/products.py:
python
router = APIRouter(prefix="/products", tags=["Products"])
@router.get("", response_model=list[ProductResponse])
def list_products(db: Db, minimum_stock: int | None = Query(default=None, ge=0)):
return repo.get_all(db, minimum_stock)
@router.get("/{product_id}", response_model=ProductResponse)
def get_product(product_id: int, db: Db):
return service.get_or_404(db, product_id)
@router.post("", response_model=ProductResponse, status_code=201)
def create_product(data: ProductCreate, db: Db, _admin: dict = Depends(require_role("admin"))):
return service.create(db, data)
@router.patch("/{product_id}", response_model=ProductResponse)
def update_product(product_id: int, data: ProductUpdate, db: Db, _admin: dict = Depends(require_role("admin"))):
return service.update(db, product_id, data)
@router.patch("/{product_id}/deactivate", response_model=ProductResponse)
def deactivate_product(product_id: int, db: Db, _admin: dict = Depends(require_role("admin"))):
return service.deactivate(db, product_id)💡
GET /productsyGET /products/{id}no tienenDepends(require_role(...))— cualquiera (incluso sin token) puede consultar el catálogo. Solo crear, actualizar y desactivar exigen ser admin. Es la misma lógica que un e-commerce real: ver productos es público, tocarlos no.?minimum_stock=10(query param opcional,Clase 6) es lo que le permitiría aorders_service, en el futuro, filtrar "solo productos con stock" — hoyproducts_client.pypide un producto puntual porid, no usa este filtro.
Alembic: mismo patrón, migración 0001_products, tabla products en orderflow_products_db (base nueva, creada junto con orderflow_orders_db — ver sql/create_databases.sql del repo).
🚀 Levantar, migrar y verificar users_service (directo y a través del gateway)
Con el código de users_service ya completo (sección 4 de PARTE TEÓRICA), esto es lo que falta para correrlo de verdad: crear la tabla con Alembic, sembrar el primer admin, correr el primer test automático, y comparar el camino directo contra el camino a través del gateway con evidencia real de Postman.
🗄️ Alembic — creando la tabla users de verdad
Mismo patrón que ya usaron en la Clase 4, adaptado a la convención app. de este proyecto (acá SÍ se importa con el prefijo app., a diferencia de la Clase 4):
bash
cd users_service
alembic init migrationsmigrations/env.py — hay que editarlo a mano después del init (por defecto no sabe nada de nuestros modelos ni de nuestro .env):
python
from app.config import settings
from app.db import Base
from app.models import User # el import registra el modelo en Base.metadata
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) # pisa alembic.ini
target_metadata = Base.metadataGenerar y aplicar la migración:
bash
alembic revision --autogenerate -m "crea tabla users"
alembic upgrade head$ docker exec bd_test_backend psql -U postgres -d users_db -c "\dt"
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+----------
public | alembic_version | table | postgres
public | users | table | postgres💡 Alembic detectó solo la tabla
usersy su índice único enix_users_email) — exactamente lo que declaramodels.py— sin escribir una sola línea de SQL a mano. Mismos 3 momentos que en la Clase 4: ① el modelo se registra enBase.metadata(RAM, nada toca la base), ②--autogeneratecompara y escribe el script de migración (migrations/versions/56c90dbb8e7f_crea_tabla_users.py), ③upgrade headlo ejecuta de verdad contra Postgres.
🆚 Probar directo vs. a través del gateway — paso a paso de los dos caminos
Acotación real del profe: probar todo pegándole directo a users_service valida el servicio, pero no valida el gateway. Acá quedan los dos caminos, uno al lado del otro, mismos requests, un solo dato distinto (el puerto).
Paso 0 — levantar los servidores que hagan falta para cada camino:
bash
# SIEMPRE hace falta users_service (los dos caminos le pegan a él, tarde o temprano)
cd users_service && source venv/bin/activate && uvicorn app.main:app --port 8001 --reload
# Solo para el camino "a través del gateway", en otra terminal:
cd api_gateway && source venv/bin/activate && uvicorn app.main:app --port 8000 --reload| Paso | 🔴 Directo a users_service | 🟢 A través de api_gateway |
|---|---|---|
| 1. Health | curl http://127.0.0.1:8001/health | curl http://127.0.0.1:8000/health |
| 2. Registrar | curl -X POST http://127.0.0.1:8001/api/v1/users -d '{...}' | curl -X POST http://127.0.0.1:8000/api/v1/users -d '{...}' |
| 3. Login | curl -X POST http://127.0.0.1:8001/api/v1/auth/login -d '{...}' | curl -X POST http://127.0.0.1:8000/api/v1/auth/login -d '{...}' |
| 4. Listar (admin) | curl http://127.0.0.1:8001/api/v1/users -H "Authorization: Bearer <token>" | curl http://127.0.0.1:8000/api/v1/users -H "Authorization: Bearer <token>" |
🌱 scripts/create_admin.py — sembrar el primer usuario admin
No hay ningún endpoint para "hacerse admin" (por diseño, sección 4) — por eso hace falta un script aparte, pensado para correrse una sola vez:
python
import os
from sqlalchemy.exc import IntegrityError
from app.db import SessionLocal
from app.models import User
from app.repository import UserRepository
from app.security import hash_password
EMAIL = os.getenv("ADMIN_EMAIL", "admin@orderflow.local")
PASSWORD = os.getenv("ADMIN_PASSWORD", "Admin123*")
NAME = os.getenv("ADMIN_NAME", "OrderFlow Admin")
def main():
db = SessionLocal()
try:
repo = UserRepository()
existing = repo.get_by_email(db, EMAIL.lower())
if existing:
print(f"Admin ya existe: {EMAIL}")
return
admin = User(
name=NAME,
email=EMAIL.lower(),
password_hash=hash_password(PASSWORD),
role="admin",
active=True,
)
db.add(admin)
db.commit()
print(f"Admin creado: {EMAIL} / {PASSWORD}")
except IntegrityError:
db.rollback()
print("No se pudo crear el admin: posible duplicado")
finally:
db.close()
if __name__ == "__main__":
main()bash
python3 -m scripts.create_admin
# Admin creado: admin@orderflow.local / Admin123*⚠️ Encontrado corriéndolo: el admin se crea sin problema (inserta el
Userdirecto con SQLAlchemy, sin pasar porUserCreate/EmailStr), pero loguearse con ese email por defecto falla —LoginRequestsí valida conEmailStr, y.locales un dominio reservado queemail-validatorrechaza. Detalle completo y solución en 06-Errores → EmailStr rechaza dominio .local.
💡 Los tres
os.getenv(..., default)dejan el script funcionando "de una" sin configurar nada (con los valores por defecto), pero permiten override sin tocar código:ADMIN_EMAIL=otro@mail.dev python3 -m scripts.create_admin. Mismo patrón que las variables de entorno en general (sección 3, callout de.env).
🧪 tests/test_health.py — el primer test del servicio
python
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
assert "X-Correlation-ID" in response.headersbash
python3 -m pytest tests/test_health.py -v💡
TestClient(app)no levanta un servidor real ni usa red — le pasa los requests directo a la app de FastAPI en memoria (más rápido, y no hace falta teneruvicorncorriendo en otra terminal para testear). El tercerassertconfirma que elcorrelation_id_middleware(sección 3) también le agrega el header a la respuesta, no solo en el gateway.
⚠️ Este test es sensible al mensaje EXACTO de
/health— si alguien cambia"status": "ok"por otro texto (a mano, probando algo), el test falla hasta que se actualice uno de los dos lados. Es justamente lo que hace un test: avisar cuando algo cambió sin que se actualizara todo lo que dependía de eso.
🔎 Evidencia real del camino 🔴 Directo, caso por caso (Postman)
Caso 1 — el Environment que define A QUÉ le vamos a pegar

Un Environment en Postman es un conjunto de variables ({{nombre}}) que todos los requests de la colección pueden reusar, en vez de escribir la URL entera en cada uno. Acá hay dos variables cargadas:
| Variable | Valor | Qué significa |
|---|---|---|
base_url | http://127.0.0.1:8001 | El puerto 8001 es el de users_service corriendo solo — no el 8000 del gateway. Todo request que use {{base_url}} le va a pegar directo al microservicio, saltándose el api_gateway por completo. |
token | eyJhbGciOiJIUzI1NiIs... (cortado) | El JWT que devolvió el último login. No lo escribiste a mano — quedó ahí porque el request de login tiene un script en la pestaña "Scripts → Post-response" (pm.environment.set("token", ...), sección 4) que lo guarda solo apenas llega la respuesta. |
El nombre del Environment ("Python Backend") está resaltado con un tilde a la izquierda — así se ve cuál es el Environment activo en esta colección (Postman permite tener varios guardados, pero solo uno activo a la vez arriba a la derecha).
💡 Es justo el dato que hay que cambiar para probar el otro camino: duplicar este Environment y cambiarle solo
base_urlahttp://127.0.0.1:8000alcanza para que los mismos requests (login, registrar, listar,PATCH,DELETE) pasen por el gateway en vez de pegarle directo ausers_service— sin tocar ni un request.
Caso 2 — la prueba de que quien respondió fue users_service, no el gateway

Este es el resultado de mandar GET {{base_url}}/health (que con el Environment de arriba resuelve a GET http://127.0.0.1:8001/health). Tres cosas para leer en esta captura:
200 OK·21 ms·272 B(barra de estado, arriba a la derecha del panel de respuesta): el request llegó, el servidor contestó bien, y tardó 21 milisegundos — confirma queusers_serviceestá corriendo y escuchando en el8001.- El body, campo por campo:json
{ "status": "Felicitaciones! El backend esta UP", "service": "Users Service", "version": "1.0.0" }"status"es el mensaje personalizado que se dejó enmain.py(health()) — no el"ok"original de la sección 4, un cambio de prueba que quedó así a propósito (ver la conversación sobretest_health.pymás arriba)."service": "Users Service"es la prueba clave de que este/healthes el deusers_servicey no el del gateway. Si el mismo request se mandara conbase_url = http://127.0.0.1:8000(el gateway), el/healthque respondería sería el propio del gateway (correlation_id_middleware+ su propio@app.get("/health"), sección 3) — que devuelve"service": "OrderFlow API Gateway", un JSON completamente distinto, porque/healthno es una de las rutas queproxy()reenvía (solo reenvía/api/v1/*).
version: "1.0.0"— coincide conapp_versionenusers_service/app/config.py(sección 4), confirmando que las variables de entorno deusers_servicese están leyendo bien.
Caso 3 — el script que hace que {{token}} se guarde solo

Este es el código exacto que vive en la pestaña Scripts → Post-response del request Login (la que se explicó como instrucción más arriba, ahora en su lugar real dentro de Postman):
javascript
const data = pm.response.json();
pm.environment.set("token", data.access_token);pm.response es el objeto de la respuesta que Postman acaba de recibir — .json() la parsea. pm.environment.set("token", ...) escribe esa variable en el Environment activo ("Python Backend"), sobrescribiendo el valor anterior. Este script corre automático, cada vez que se manda el request — por eso el token del Caso 1 siempre está actualizado con el último login, sin copiar/pegar nada a mano.
Caso 4 — el login real, con las credenciales del admin creado por create_admin.py

POST {{base_url}}/api/v1/auth/login, con el body:
json
{
"email": "admin@orderflow.dev",
"password": "Admin123*"
}— exactamente las credenciales que quedaron sembradas con ADMIN_EMAIL="admin@orderflow.dev" ADMIN_PASSWORD="Admin123*" python3 -m scripts.create_admin (sección 4). La respuesta, 200 OK · 86 ms · 419 B:
json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer"
}Es el JWT completo (header.payload.signature, sección 4) — y es este mismo request el que dispara el script del Caso 3 apenas llega la respuesta, dejando {{token}} listo para usar en el siguiente request (/me, /users, PATCH, DELETE) sin copiarlo a mano.
Caso 5 — la prueba en la base de datos: las contraseñas nunca se guardan en texto plano

Esto es DataGrip (cliente de base de datos) conectado a orderflow_users_db → tabla users, con los 3 usuarios reales creados durante las pruebas de hoy. La columna clave acá es password_hash:
$argon2id$v=19$m=65536,t=3,p=4$aauoAeFAnA30c...Ese es el resultado real de hash_password() (PasswordHash.recommended() de pwdlib, sección 4) — confirma en la práctica lo que se explicó en teoría:
argon2ides el algoritmo quepwdlibeligió como "recomendado" — no hay ni un solo caracter de"Admin123*"(la contraseña real) guardado en ningún lado.v=19$m=65536,t=3,p=4son los parámetros de costo de Argon2 (versión, memoria, iteraciones, paralelismo) — es lo que hace que hashear sea lento a propósito (para que probar contraseñas por fuerza bruta contra esta tabla sea carísimo).- Cada hash es distinto aunque dos usuarios tuvieran la misma contraseña (por la "sal" aleatoria que Argon2 mezcla en cada hash) — ni comparando esta columna se puede saber si dos cuentas comparten password.
Caso 6 — el paso 4 de la tabla comparativa: listar usuarios con el token


Este es el request GET {{base_url}}/api/v1/users (el paso 4 de la tabla comparativa de más arriba), con:
- Auth Type: Bearer Token, Token:
{{token}}— en la pestaña Authorization del request. Postman arma solo el headerAuthorization: Bearer <valor de token>a partir de esto (el aviso gris "The authorization header will be automatically generated..." lo confirma) — no hace falta escribir el header a mano. 200 OK · 22 ms · 451 B, con el array completo de usuarios — porque eltokenusado es el del admin (role: "admin", id3), así que pasa el chequeo derequire_role("admin")(sección 4) sin problema.
Con este caso quedan cubiertos, con evidencia real, 3 de los 4 pasos de la tabla comparativa del camino 🔴 Directo: health (Caso 2), login (Caso 4, con el script del Caso 3 guardando el token), y listar (este Caso 6).
Caso 7 — el paso 2 de la tabla comparativa: registrar (con Postman, no solo curl)

POST {{base_url}}/api/v1/users, body:
json
{
"name": "Pedro Quispe",
"email": "pedro@orderflow.dev",
"password": "password123"
}Respuesta 201 Created · 89 ms · 274 B:
json
{
"id": 7,
"name": "Pedro Quispe",
"email": "pedro@orderflow.dev",
"role": "user",
"active": true
}Con esto, los 4 pasos de la tabla comparativa (health, registrar, login, listar) ya tienen evidencia real en Postman para el camino 🔴 Directo.
💡 Nótese en la barra de tabs de arriba de Postman: hay 6 requests guardados en la colección (
Registrar,Login,/me, otroLogin,Listar,PATCH Actualizar,DELETE Eliminar) — la colección completa ya cubre los 3 endpoints originales (sección 4) más los 2 agregados a mano (PATCH/DELETE, más arriba en esta misma parte práctica).
Caso 8 — GET /{user_id}: ver un usuario puntual (el tercer endpoint, fuera de la tabla)

GET {{base_url}}/api/v1/users/7 (el 7 es el id que devolvió el registro del Caso 7) — responde 200 OK · 40 ms · 269 B con los datos de ese usuario puntual. Este endpoint (get_user, sección 4) no estaba en la tabla de 4 pasos porque no es estrictamente necesario para el flujo de auth, pero también está protegido con require_role("admin") — por eso, igual que en el Caso 6, hace falta el {{token}} del admin en la pestaña Authorization (el punto verde al lado de "Authorization" confirma que tiene algo configurado ahí).
Caso 9 — PATCH + GET de verificación: el cambio queda guardado de verdad

PATCH {{base_url}}/api/v1/users/7, body:
json
{ "name": "Guillermo Quispe" }Responde 200 OK · 57 ms · 273 B con el usuario completo, name ya cambiado:
json
{
"id": 7,
"name": "Guillermo Quispe",
"email": "pedro@orderflow.dev",
"role": "user",
"active": true
}Fijate que el email no cambió (sigue pedro@orderflow.dev) aunque el name sí — es justo el comportamiento de UserUpdate (sección "🧱 El orden para agregar un endpoint nuevo", más arriba): solo name y active son actualizables, y encima el PATCH es parcial — como el body solo mandó name, el resto de los campos del usuario quedó intacto.
La prueba de que el cambio persistió de verdad (no solo lo que devolvió el PATCH, que técnicamente podría estar mintiendo si el service.py tuviera un bug):

Un GET {{base_url}}/api/v1/users/7 aparte, después del PATCH, devuelve exactamente el mismo name: "Guillermo Quispe" — confirma que self.repo.update(db, user) (repository.py, sección 4) hizo db.commit() de verdad contra orderflow_users_db, no que el PATCH devolvió un objeto en memoria sin guardar nada.
🧪 Tip de entrevista / buena práctica de testing: verificar un
PATCH/POST/DELETEcon unGETposterior e independiente es un patrón común para confirmar que un cambio persistió — la respuesta del propio endpoint que modifica datos no es prueba suficiente por sí sola (podría estar devolviendo el objeto en memoria antes de siquiera intentar elcommit()).
Caso 10 — DELETE + GET de verificación: cierra el CRUD completo

DELETE {{base_url}}/api/v1/users/7, con Authorization: Bearer {{token}} (mismo patrón que el resto de endpoints admin). Responde 204 No Content · 27 ms · 169 B — el body queda vacío (el panel de respuesta no muestra ni una línea de JSON), que es exactamente lo que se explicó en la sección de routers/users.py: delete_user() no tiene return, y status_code=204 significa "salió bien, no hay nada que devolver".

Mismo patrón de verificación que el Caso 9: un GET {{base_url}}/api/v1/users/7aparte, después del DELETE, devuelve 404 Not Found:
json
{ "detail": "Usuario no encontrado" }Ese mensaje es el de get_or_404() (service.py, sección 4) — confirma que self.repo.delete(db, user) borró la fila de verdad en orderflow_users_db (no que el 204 fue un "sí" falso). El usuario id: 7 (Pedro Quispe → Guillermo Quispe, Casos 7 y 9) ya no existe en ningún lado.
Con este caso, los 5 endpoints de users_service (POST, GET lista, GET /{id}, PATCH, DELETE) quedan probados con evidencia real de Postman, cada uno con su verificación correspondiente donde aplica. El CRUD completo, de punta a punta, andando.
🟢 Evidencia real del camino 🟢 Gateway, caso por caso (Postman)
Caso 11 — una segunda variable en el MISMO Environment, en vez de duplicarlo

En vez de duplicar el Environment "Python Backend" completo (que era la sugerencia inicial), acá se agregó una variable nueva dentro del mismo Environment:
| Variable | Valor | Para qué |
|---|---|---|
base_url | http://127.0.0.1:8001 | El camino 🔴 Directo (users_service solo) — sin cambios |
token | (con ícono de 🔒 secreto) | Igual que siempre, pero marcada como variable secreta en Postman — no se muestra en texto plano ni se comparte al exportar el Environment |
base_url_gateway | http://127.0.0.1:8000 | El camino nuevo 🟢, a través del api_gateway |
💡 Es un enfoque más prolijo que duplicar el Environment completo: con las dos variables (
base_urlybase_url_gateway) conviviendo en el mismo Environment, un mismo request puede probarse contra cualquiera de los dos caminos con solo cambiar{{base_url}}por{{base_url_gateway}}en la URL — sin mantener dos Environments sincronizados (que se puede desincronizar si cambiás algo en uno y te olvidás del otro). El costo: hay que crear un request aparte (o editar la URL a mano) por cada endpoint que se quiera probar por el camino gateway, en vez de solo cambiar el Environment activo.
Caso 12 — la prueba clave: el /health que responde es el del GATEWAY

Nuevo request "Health Gateway": GET {{base_url_gateway}}/health. Respuesta 200 OK · 20 ms · 230 B:
json
{
"status": "ok",
"service": "OrderFlow API Gateway"
}Comparado con el Caso 2 (GET {{base_url}}/health, directo a users_service), que devolvía "service": "Users Service" y el status personalizado "Felicitaciones! El backend esta UP" — acá es un JSON completamente distinto, porque, como se explicó en la sección 3, /health no es una de las rutas que proxy() reenvía (solo reenvía /api/v1/*). Este /health lo responde el propio api_gateway/app/main.py (@app.get(path="/health", ...), sección 3) — es la prueba concreta y directa de que el Environment está apuntando al gateway y no al microservicio.
Caso 13 — el primer intento de login por el gateway: 404, por un detalle de la URL

POST {{base_url_gateway}}/api/auth/login — 404 Not Found:
json
{ "detail": "Not Found" }Antes de sospechar del Environment (que estaba perfecto), la pista clave para diagnosticar esto es el origen del 404: es un {"detail": "Not Found"} genérico de FastAPI (no el {"detail": "Credenciales inválidas"} que devuelve auth.py cuando el login falla por mal usuario/contraseña) — eso significa que la petición ni siquiera encontró una ruta que la atienda, todavía no llegó a ejecutarse ningún código de negocio. La causa: faltaba /v1 en la URL (/api/auth/login en vez de /api/v1/auth/login) — ver el callout de arriba y el error completo documentado en 06-Errores.
Caso 14 — corregido: login exitoso a través del gateway

Con la URL corregida — POST {{base_url_gateway}}/api/v1/auth/login — responde 200 OK · 119 ms · 419 B, con el mismo access_token/token_type que en el camino directo (Caso 4). Comparando los tiempos: este login tardó 119 ms contra los 86 ms del Caso 4 directo — la diferencia (~33 ms) es el "salto" extra que agrega el gateway: recibir la petición, armar los headers, reenviarla por HTTP a users_service, y traer la respuesta de vuelta. Es el costo real, medible, de la capa de indirección que agrega un API Gateway.
Caso 15 — script post-response y Environment final: dos tokens conviviendo

Mismo mecanismo que el Caso 3 (pm.response.json() + pm.environment.set(...)), pero guardando el resultado en token2, no en token:
javascript
const data2 = pm.response.json();
pm.environment.set("token2", data2.access_token);Necesario porque es el mismo Environment "Python Backend" el que se usa para los dos caminos (Caso 11) — si el script de este request pisara la misma variable token que usa el camino directo, cada vez que se probara un camino se invalidaría el token guardado del otro.

El Environment terminado, con los 4 valores conviviendo:
| Variable | Valor | Camino |
|---|---|---|
base_url | http://127.0.0.1:8001 | 🔴 Directo |
token | (JWT, marcado como secreto 🔒) | 🔴 Directo |
base_url_gateway | http://127.0.0.1:8000 | 🟢 Gateway |
token2 | (JWT distinto) | 🟢 Gateway |
Con esto, cualquier request de la colección puede probarse por cualquiera de los dos caminos con solo elegir qué par de variables usar en la URL y en Authorization — sin duplicar ni un solo request, ni mantener dos Environments sincronizados.
Caso 16 — el paso 4 de la tabla comparativa, a través del gateway: listar usuarios

GET {{base_url_gateway}}/api/v1/users, con Authorization: Bearer Token → {{token2}} (el token del Caso 14, sembrado por el script del Caso 15). Responde 200 OK · 132 ms · 451 B, con exactamente los mismos 3 usuarios que en el Caso 6 (camino directo): Styp Canto (admin), Julio Bernable y Carmen Jula (ambos user) — misma base de datos (orderflow_users_db), dos caminos distintos para llegar a ella.
Comparando los tiempos otra vez: 132 ms acá contra 22 ms en el Caso 6 directo — consistente con el "impuesto" de ~100 ms extra que ya se había medido en el login (Caso 14), el costo real del salto adicional gateway → users_service.
Con este caso, los 4 pasos de la tabla comparativa (health: Caso 12, registrar: Caso 17, login: Caso 14, listar: este Caso 16) quedan completos también para el camino 🟢 Gateway.
Caso 17 — el paso 2 de la tabla comparativa, a través del gateway: registrar

POST {{base_url_gateway}}/api/v1/users, body:
json
{
"name": "Hugo Sanchez",
"email": "hugo@orderflow.dev",
"password": "password123"
}Responde 201 Created · 114 ms · 273 B con el usuario creado (id: 8, role: "user", sin password) — idéntico comportamiento al Caso 7 (directo), solo que esta vez la petición pasó por proxy() antes de llegar a UserService.register(). Con este caso, los 4 pasos de la tabla comparativa quedan 100% cubiertos en los dos caminos (🔴 Directo: Casos 2/7/4/6 — 🟢 Gateway: Casos 12/17/14/16).
Caso 18 — GET /{user_id} a través del gateway: el usuario recién creado

GET {{base_url_gateway}}/api/v1/users/8 (el id: 8 que devolvió el Caso 17) — 200 OK · 36 ms · 268 B con los datos de Hugo Sanchez. Mismo endpoint que el Caso 8 (directo), ahora verificado también pasando por el gateway.
🟢 (sigue completándose con
PATCH/DELETEa través del gateway, para cerrar el CRUD completo en los dos caminos)
Lo único que cambia en cada fila es el puerto (8001 → 8000) — la ruta, el método, el body y los headers son idénticos, porque proxy() reenvía todo tal cual (sección 3). Verificado en terminal, camino por camino:
🔴 DIRECTO (8001) 🟢 GATEWAY (8000)
$ curl -X POST .../8001/api/v1/auth/login $ curl -X POST .../8000/api/v1/auth/login
-d '{"email":"admin@orderflow.dev", -d '{"email":"admin@orderflow.dev",
"password":"Admin123*"}' "password":"Admin123*"}'
{"access_token":"eyJ...", "token_type": {"access_token":"eyJ...", "token_type":
"bearer"} "bearer"}
$ curl .../8001/api/v1/users \ $ curl .../8000/api/v1/users \
-H "Authorization: Bearer <token>" -H "Authorization: Bearer <token>"
[{"id":3,"name":"OrderFlow Admin", ...}] [{"id":3,"name":"OrderFlow Admin", ...}]Qué SÍ prueba cada camino, y qué NO:
| 🔴 Directo | 🟢 Gateway | |
|---|---|---|
¿users_service funciona (DB, JWT, roles)? | ✅ Sí | ✅ También (indirectamente) |
¿El routing del gateway (proxy()) está bien armado? | ❌ No lo toca | ✅ Sí |
¿Se propaga bien el header Authorization? | N/A (no hay gateway en el medio) | ✅ Sí |
| ¿Es el camino que usaría un cliente real (frontend, app)? | ❌ Nunca — users_service no queda expuesto en producción | ✅ Sí, es el único punto de entrada real |
💡 Por eso probar solo el camino directo no alcanza: podés tener
users_serviceperfecto y el gateway roto (una URL mal en.env, unprefixque no coincide) y el camino directo jamás te lo va a mostrar — solo lo ve el camino 🟢.
En Postman: el único cambio real es la variable base_url del Environment — http://127.0.0.1:8001 (directo) o http://127.0.0.1:8000 (gateway). Los requests (login, registrar, listar, PATCH, DELETE) son exactamente los mismos para los dos; alcanza con duplicar el Environment y cambiarle el puerto para tener ambos caminos listos sin editar cada request.
⚙️ .env.example real — dos correcciones sobre lo que se había armado
bash
APP_NAME=Users Service
APP_VERSION=1.0.0
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/orderflow_users_db
JWT_SECRET=CAMBIAR-ESTA-CLAVE-PARA-EL-LABORATORIO
JWT_ALGORITHM=HS256
ACCESS_TOKEN_MINUTES=30| Lo que se había armado (antes de esta captura) | Lo real | Diferencia |
|---|---|---|
postgresql://... | postgresql+psycopg://... | El driver es psycopg (versión 3), no psycopg2 — SQLAlchemy elige el driver según el prefijo de la URL. |
Base users_db | Base orderflow_users_db | Nombre distinto — se creó la base nueva con el nombre real y se migró la config para apuntar ahí. |
📝 Corrección aplicada en el proyecto del ejercicio: se instaló
psycopg[binary], se creóorderflow_users_db(y se borró lausers_dbvieja, que había quedado sin uso), y se corrióalembic upgrade headde nuevo contra la base correcta.
🧩 11. Ejercicio resuelto — manejo de microservicios (crear un pedido, de punta a punta)
En qué consiste este ejercicio: hasta acá, cada sección probó una pieza por separado — el Gateway enrutando (sección 3), el JWT autenticando (sección 4), orders_service hablándole a products_service por REST (sección 2). Este ejercicio junta las tres piezas en una sola operación de negocio real: crear un pedido, que por diseño no puede resolverse con un solo servicio — necesita que api_gateway, users_service (para el login previo), orders_service y products_service funcionen los 4 al mismo tiempo, coordinados, sin que ninguno sepa los detalles internos de los otros. Se resolvió dos veces, con la misma secuencia de pasos: primero con curl (abajo) y después con Postman, paso a paso guiado (más abajo, con capturas propias).
🗺️ Diagrama: la secuencia completa del ejercicio
📎 Fuente editable en
04-Recursos/diagramas/clase-07-ejercicio-crear-pedido-secuencia.html.
Son dos ciclos HTTP separados, uno detrás del otro, a través del mismo gateway:
- Login (cliente → gateway →
users_service→ gateway → cliente): consigue el JWT. Todavía no existe ningúnX-Correlation-IDpropio de esta operación. - Crear el pedido (cliente → gateway →
orders_service→products_service→ ... → cliente): recién acá el gateway genera elX-Correlation-ID(no venía ninguno en el request) y ese mismo ID viaja sin cambiar por las 3 llamadas restantes — es la barra de activación larga deorders_servicela que muestra que ese servicio se queda "en el medio", esperando la respuesta deproducts_serviceantes de poder contestarle al gateway.
🗺️ Diagrama: qué decide OrderService.create() por dentro
El diagrama de secuencia de arriba muestra el mensaje GET PRODUCT como una sola flecha — pero no muestra qué hace orders_service con esa respuesta. Ese detalle de caja negra (las 2 reglas de negocio reales que deciden si el pedido termina en 201 o se corta con un 409 — el caso de error del paso 6 del ejercicio) ya tiene su propio diagrama de flujo: 🗺️ Diagrama: las reglas de negocio de create(), como flujo, un poco más arriba.
🗺️ Diagrama: de dónde sale cada dato del pedido
Y este es el modelo de datos que queda detrás — no es UML de clases (no hay herencia ni métodos: /diagram-design no tiene ese tipo de diagrama, así que esto es una aproximación con notación de entidad-relación). Lo importante para el ejercicio: Order.user_id y Order.product_id parecen claves foráneas, pero no lo son — orderflow_orders_db es una base de datos completamente distinta de orderflow_users_db y orderflow_products_db (mismo patrón database-per-service de la sección 2), así que no hay ningún FOREIGN KEY real entre ellas. Por eso product_name y unit_price se copian dentro del pedido al momento de crearlo — es la única forma de que el pedido "recuerde" ese dato aunque el producto cambie de precio después.
📎 Fuente editable en
04-Recursos/diagramas/clase-07-modelo-datos-pedido.html.
Con users_service, products_service, orders_service y api_gateway corriendo a la vez (puertos 8001/8002/8004/8000), el flujo completo del diagrama de secuencia (sección 4) funciona de verdad:
bash
GW=http://127.0.0.1:8000
# 1) Login (gateway -> users_service)
TOKEN=$(curl -s -X POST $GW/api/v1/auth/login -H "Content-Type: application/json" \
-d '{"email":"admin@orderflow.dev","password":"Admin123*"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 2) Crear un producto (gateway -> products_service)
curl -s -X POST $GW/api/v1/products -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sku":"TEC-001","name":"Teclado mecánico","price":89.90,"stock":15}'
# {"sku":"TEC-001","name":"Teclado mecánico","price":"89.90","stock":15,"id":1,"active":true}
# 3) Crear un PEDIDO (gateway -> orders_service -> products_service, todo junto)
curl -si -X POST $GW/api/v1/orders -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"product_id":1,"quantity":2}'HTTP/1.1 201 Created
x-correlation-id: 0a4b235e-a47a-4a5f-abd7-e2720edb0e7a
{"id":1,"user_id":3,"product_id":1,"product_name":"Teclado mecánico",
"unit_price":"89.90","quantity":2,"total":"179.80","status":"Creado",
"correlation_id":"0a4b235e-a47a-4a5f-abd7-e2720edb0e7a", ...}Verificado también directo en la base (orderflow_orders_db): el pedido quedó persistido con el mismo correlation_id que trae el header de la respuesta — el hilo completo, de punta a punta, es rastreable con ese único ID.
📌 Este
201es el resultado de TODO lo que se documentó en esta clase, andando junto: el gateway enrutó y propagóAuthorization/X-Correlation-ID(sección 3);orders_servicevalidó el JWT con elJWT_SECRETcompartido (sección 4);OrderService.create()le preguntó aproducts_servicepor HTTP (sección 2,products_client.py) si había stock; y el pedido quedó guardado con el precio copiado al momento de la compra (unit_price), no un link al producto.
⚠️ Un detalle real del código, no un bug de la documentación:
OrderService.create()validastock >= quantityantes de crear el pedido, pero no descuenta el stock deproducts_servicedespués. Repetir la misma compra no baja el stock disponible — es una limitación real del proyecto tal como está en el repo del curso, no algo que se haya introducido acá.
Mismo flujo, resuelto como ejercicio práctico guiado, con Postman en vez de curl:
Este bloque no es código del profe ni una demo ya armada — es un ejercicio práctico que se resolvió siguiendo estas instrucciones, paso a paso, con los 4 servicios ya migrados y con un producto real sembrado en la base (id: 1, "Teclado mecánico", stock 15):
- Levantar los 4 servicios, cada uno en su terminal con su propio
venv:bashcd users_service && source venv/bin/activate && uvicorn app.main:app --port 8001 --reload cd products_service && source venv/bin/activate && uvicorn app.main:app --port 8002 --reload cd orders_service && source venv/bin/activate && uvicorn app.main:app --port 8004 --reload cd api_gateway && source venv/bin/activate && uvicorn app.main:app --port 8000 --reload - Conseguir un token válido (no hace falta que sea admin —
create_ordersolo exige estar logueado):POST {{base_url_gateway}}/api/v1/auth/logincon{"email":"admin@orderflow.dev","password":"Admin123*"}, guardado solo en{{token2}}por el mismo script de Post-response de siempre. - Armar el request:
POST {{base_url_gateway}}/api/v1/orders,Authorization: Bearer {{token2}}, body{"product_id": 1, "quantity": 2}. - Mandarlo y leer la respuesta —
201 Created, con elcorrelation_idviniendo directo en el body (no solo en el header). - Mirar las 3 terminales (gateway, orders, products) para confirmar que el mismo ID apareció en las tres — ver el callout de instrumentación más abajo.
- Reforzar con el caso de error: mandar
{"product_id": 1, "quantity": 999}(más que el stock) y confirmar que responde409 Conflict— "Stock insuficiente" — sin llegar a crear el pedido.
⚙️ El cambio de código que hizo falta para el paso 5 — y por qué
correlation_id_middleware (secciones 3 y 4) guarda el ID en request.state, pero no lo imprime en ningún lado — uvicorn --reload por defecto solo loguea método, ruta y status de cada request (INFO: 127.0.0.1:0 - "POST /api/v1/orders HTTP/1.1" 201), sin ningún header custom. Es decir: el X-Correlation-ID viaja perfecto entre los 3 servicios aunque no se vea en ningún lado — pero si no se agrega algo que lo imprima, no hay forma de comprobarlo a simple vista mirando las terminales, solo confiando en que el código está bien escrito. Para el paso 5 (ver el mismo ID en las 3 terminales) hacía falta una línea de más.
Se agregó la misma línea, en el mismo lugar (justo después de request.state.correlation_id = correlation_id, antes de await call_next(request)), en los 3 servicios que participan de esta cadena — no en users_service, que no interviene en crear un pedido:
api_gateway/app/main.py:
diff
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid4())
request.state.correlation_id = correlation_id
+ print(f"🔗 [{settings.app_name}] {request.method} {request.url.path} · X-Correlation-ID={correlation_id}")
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return responseorders_service/app/main.py:
diff
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid4())
request.state.correlation_id = correlation_id
+ print(f"🔗 [{settings.app_name}] {request.method} {request.url.path} · X-Correlation-ID={correlation_id}")
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return responseproducts_service/app/main.py:
diff
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid4())
request.state.correlation_id = correlation_id
+ print(f"🔗 [{settings.app_name}] {request.method} {request.url.path} · X-Correlation-ID={correlation_id}")
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return responsePor qué esa línea, ahí, y no en otro lado:
- Justo después de
request.state.correlation_id = correlation_id— en ese punto ya se resolvió si el ID venía de otro servicio (headerX-Correlation-IDde entrada) o si el propio middleware tuvo que generar uno nuevo (uuid4()). Ponerlo una línea antes hubiera impreso el header crudo, no el valor final que realmente va a viajar. - Antes de
await call_next(request)— así elprintsale apenas llega el request a ese servicio, no cuando ya terminó de procesarlo; en las 3 terminales, el orden de aparición de las líneas coincide con el orden real de los saltos (gateway → orders → products), reforzando visualmente la secuencia. f"[{settings.app_name}] ..."— cada servicio ya tiene su propioapp_nameenconfig.py("OrderFlow API Gateway", "Orders Service", "Products Service"); reusarlo evita hardcodear el nombre y hace que la línea sea inmediatamente identificable en cada terminal sin tener que recordar qué puerto es cuál.- Es puramente instrumentación, no lógica: no lee nada nuevo, no cambia el
response, no afecta el comportamiento — si se borra esa línea, el header y elcorrelation_iddel body siguen viajando exactamente igual. Solo deja de verse en la terminal. - No se tocó
users_serviceporque no participa de la cadenacrear pedido → pedir producto— agregarle la misma línea ahí no hubiera aportado nada a esta prueba.
Antes de mandar el request, se confirmó que los 4 servicios estuvieran arriba:
| Puerto | Servicio | Estado |
|---|---|---|
| 8001 | users_service | ✅ 200 OK |
| 8002 | products_service | ✅ 200 OK |
| 8004 | orders_service | ✅ 200 OK |
| 8000 | api_gateway | ✅ 200 OK |

POST {{base_url_gateway}}/api/v1/orders, con Authorization: Bearer {{token2}} y body {"product_id": 1, "quantity": 2}:

201 Created · 219 ms · 425 B — el correlation_id (b6a2c4de-1203-4d0c-a95a-2aa11f03e733) viene directo en el body de la respuesta, no solo en el header X-Correlation-ID.
🧪 Cómo se confirmó que ese mismo ID viajó por los 3 servicios: el
correlation_id_middleware(secciones 3 y 4) guarda el ID enrequest.state, pero por defecto no lo imprime en ningún lado — para verlo de verdad hace falta agregar una línea de log/🔗 [OrderFlow API Gateway] POST /api/v1/orders · X-Correlation-ID=b6a2c4de-1203-4d0c-a95a-2aa11f03e733 🔗 [Orders Service] POST /api/v1/orders · X-Correlation-ID=b6a2c4de-1203-4d0c-a95a-2aa11f03e733 🔗 [Products Service] GET /api/v1/products/1 · X-Correlation-ID=b6a2c4de-1203-4d0c-a95a-2aa11f03e733Esa tercera línea es la prueba de que
orders_servicesí le pasó elX-Correlation-IDaproducts_serviceal pedirle el producto (products_client.py, sección 2) — no generó uno nuevo, propagó el mismo.
🏋️ 12. EJERCICIOS CON SOLUCIÓN
Ejercicio 1 — Registrar tu propio segundo usuario, a través del gateway
Armá un request nuevo: POST {{base_url_gateway}}/api/v1/users, con vos como body (nombre, email, password a tu elección).
🎯 Qué deberías lograr: un 201 Created cuya respuesta no incluya password ni password_hash — solo id, name, email, role ("user" por defecto) y active.
💡 ¿Sabías que…? — por qué la respuesta nunca trae la contraseña
UserResponse (sección 4) es un schema de salida distinto al de entrada (UserCreate) — solo declara los campos que sí querés exponer. Aunque el modelo User sí tiene password_hash guardado, Pydantic solo serializa lo que el schema de respuesta define.
python
# ejemplo de referencia — otro par de schemas con la misma idea
class ContactCreate(BaseModel):
name: str
secret_pin: str
class ContactResponse(BaseModel):
id: int
name: str # secret_pin nunca aparece acáVer solución
En Postman:
- Method POST, URL
{{base_url_gateway}}/api/v1/users. - Pestaña Body → raw → JSON:json
{ "name": "Tu Nombre", "email": "tunombre@orderflow.dev", "password": "unaClaveSegura1" } - Send — esperás
201 Created.
Con curl (equivalente):
bash
curl -s -X POST http://127.0.0.1:8000/api/v1/users \
-H "Content-Type: application/json" \
-d '{"name":"Tu Nombre","email":"tunombre@orderflow.dev","password":"unaClaveSegura1"}'Ejercicio 2 — Login por el gateway, guardando el token en una variable propia
Logueate con el usuario del Ejercicio 1, apuntando a {{base_url_gateway}}, y en Scripts → Post-response guardalo en una variable nueva: token3 (para no pisar token/token2, que ya usás para admin).
🎯 Qué deberías lograr: después de mandar el request, el Environment debería tener una variable token3 con un JWT nuevo — confirmalo abriendo el ícono del ojo 👁️ y mirando el valor.
💡 ¿Sabías que…? — por qué conviene una variable por "identidad"
Cada pm.environment.set(...) sobrescribe el valor anterior de esa variable. Si todos los logins guardan en token, perdés el token anterior cada vez — por eso el Environment terminó con token/token2 (Caso 15): una variable por identidad que querés tener lista para usar en paralelo.
javascript
// ejemplo de referencia — mismo patrón, otra variable
const data = pm.response.json();
pm.environment.set("adminBackupToken", data.access_token);Ver solución
En Postman:
- Method POST, URL
{{base_url_gateway}}/api/v1/auth/login. - Body → raw → JSON:
{"email":"tunombre@orderflow.dev","password":"unaClaveSegura1"} - Pestaña Scripts → Post-response:javascript
const data = pm.response.json(); pm.environment.set("token3", data.access_token); - Send — mirá el Environment (👁️) y confirmá que
token3tiene un valor nuevo.
Con curl:
bash
TOKEN3=$(curl -s -X POST http://127.0.0.1:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"tunombre@orderflow.dev","password":"unaClaveSegura1"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")Ejercicio 3 — Confirmar que tu usuario nuevo NO puede listar usuarios
Con {{token3}} (rol "user"), armá GET {{base_url_gateway}}/api/v1/users.
🎯 Qué deberías lograr: un 403 Forbidden — no una lista vacía, no un 200. Anotá el detail exacto del body.
💡 ¿Sabías que…? — 403, no 404 ni 200 con lista vacía
require_role("admin") (sección 4) no filtra ni oculta datos — corta la petición ANTES de que list_users() se ejecute. Por eso nunca ves "una lista vacía", ves un 403 Forbidden explícito.
python
# ejemplo de referencia — mismo patrón en otro endpoint imaginario
@router.get("/reportes", dependencies=[Depends(require_role("gerente"))])
def ver_reportes(): ...Ver solución
En Postman:
- Method GET, URL
{{base_url_gateway}}/api/v1/users. - Authorization → Bearer Token →
{{token3}}. - Send —
403 Forbidden,{"detail":"Permiso insuficiente"}.
Con curl:
bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/api/v1/users \
-H "Authorization: Bearer $TOKEN3"
# 403Ejercicio 4 — El único que va DIRECTO: comparar contra el gateway
Repetí el registro + login de un cuarto usuario, pero esta vez pegándole directo a users_service ({{base_url}}, puerto 8001) en vez de al gateway.
🎯 Qué deberías lograr: confirmar que el resultado es idéntico (mismo 201, mismo formato de token) — la única diferencia real es el puerto en la URL, nunca el body ni el comportamiento.
💡 ¿Sabías que…? — la única diferencia real es el puerto
proxy() (sección 3) reenvía el body, los headers y el método tal cual — por eso ningún request cambia, solo la variable de Environment que uses en la URL. Es la comparación que ya se documentó en detalle en "🆚 Probar directo vs. a través del gateway" (sección 4) — este ejercicio te la hace repetir con tus propios datos.
Ver solución
En Postman: duplicá el request del Ejercicio 1, cambiá {{base_url_gateway}} por {{base_url}} en la URL, dejá el resto igual.
Con curl:
bash
curl -s -X POST http://127.0.0.1:8001/api/v1/users \
-H "Content-Type: application/json" \
-d '{"name":"Cuarto","email":"cuarto@orderflow.dev","password":"otraClave1"}'Ejercicio 5 — Crear tu primer producto (nunca probado en Postman hasta ahora)
POST {{base_url_gateway}}/api/v1/products, con tu admin ({{token2}}). Body: un SKU, nombre, precio y stock a tu elección.
🎯 Qué deberías lograr: un 201 Created con el sku devuelto en mayúsculas aunque lo hayas escrito en minúsculas — y guardar el id en una variable nueva productId (Scripts → Post-response).
💡 ¿Sabías que…? — el SKU se normaliza solo
ProductCreate tiene un @field_validator que pasa el sku a mayúsculas ("tec-001" → "TEC-001") — no hace falta que lo escribas ya en mayúsculas.
python
# ejemplo de referencia — mismo patrón normalizando otro campo
@field_validator("codigo_postal")
@classmethod
def limpiar(cls, v: str) -> str:
return v.strip().replace(" ", "")Ver solución
En Postman:
- Method POST, URL
{{base_url_gateway}}/api/v1/products. - Authorization → Bearer Token →
{{token2}}. - Body → raw → JSON:
{"sku":"mou-002","name":"Mouse inalámbrico","price":25.50,"stock":30} - Scripts → Post-response:javascript
pm.environment.set("productId", pm.response.json().id); - Send —
201 Created,"sku": "MOU-002".
Con curl:
bash
curl -s -X POST http://127.0.0.1:8000/api/v1/products \
-H "Authorization: Bearer $TOKEN2" -H "Content-Type: application/json" \
-d '{"sku":"mou-002","name":"Mouse inalámbrico","price":25.50,"stock":30}'Ejercicio 6 — Desactivar ese producto, y volver a intentarlo
PATCH {{base_url_gateway}}/api/v1/products/{{productId}}/deactivate, dos veces seguidas.
🎯 Qué deberías lograr: la primera vez, 200 OK con "active": false. La segunda vez, un 409 Conflict — no otro 200.
💡 ¿Sabías que…? — por qué la segunda vez da error
deactivate() (sección práctica) chequea if not product.active antes de tocar nada — si ya estaba inactivo, no lo "vuelve a desactivar" en silencio, corta con 409 Conflict. Es el mismo patrón defensivo que register() de users_service usa contra emails duplicados.
Ver solución
En Postman: Method PATCH, URL {{base_url_gateway}}/api/v1/products/{{productId}}/deactivate, Authorization Bearer {{token2}}, sin body. Send dos veces.
Con curl:
bash
curl -s -X PATCH "http://127.0.0.1:8000/api/v1/products/$PRODUCT_ID/deactivate" \
-H "Authorization: Bearer $TOKEN2"
# primera vez → 200 OK, "active": false
# segunda vez → 409 Conflict, {"detail":"El producto ya está inactivo"}Ejercicio 7 — Confirmar qué endpoints de productos son públicos y cuáles no
Probá, en este orden: GET {{base_url_gateway}}/api/v1/products sin ningún token; después POST {{base_url_gateway}}/api/v1/products con {{token3}} (rol "user").
🎯 Qué deberías lograr: el GET sin token responde 200 OK (público). El POST con un usuario no-admin responde 403 Forbidden.
💡 ¿Sabías que…? — "ver" es público, "tocar" no
list_products/get_product (routers/products.py) no tienen Depends(require_role(...)) — cualquiera los puede llamar, con o sin token. Es la misma lógica que un catálogo de e-commerce real: mirar productos no requiere cuenta, comprarlos o editarlos sí.
Ver solución
En Postman: dos requests — GET .../products con Authorization en No Auth; POST .../products con Bearer {{token3}} y cualquier body de producto.
Con curl:
bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/api/v1/products
# 200
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8000/api/v1/products \
-H "Authorization: Bearer $TOKEN3" -H "Content-Type: application/json" -d '{}'
# 403Ejercicio 8 — Tu primer test automático en Postman (pm.test)
En el request GET {{base_url_gateway}}/health (el /health del gateway), agregá en Scripts → Post-response un test que falle si el status_code no es 200, o si el body no tiene la clave "service".
🎯 Qué deberías lograr: al mandar el request, la pestaña Test Results (al lado de "Body"/"Cookies") muestra 2 checks en ✅ verde — no solo mirar la respuesta a ojo.
💡 ¿Sabías que…? — pm.test() es distinto a pm.environment.set()
pm.environment.set(...) (que ya usaste, Ejercicio 2) guarda un valor. pm.test(nombre, función) verifica algo y marca ✅/❌ en "Test Results" — no cambia ninguna variable, es solo una aserción automática.
javascript
// ejemplo de referencia — test sobre otro endpoint
pm.test("el login devuelve un token", () => {
pm.expect(pm.response.json()).to.have.property("access_token");
});Ver solución
En Postman: request GET {{base_url_gateway}}/health → pestaña Scripts → Post-response:
javascript
pm.test("health responde 200", () => {
pm.response.to.have.status(200);
});
pm.test("el body trae service", () => {
pm.expect(pm.response.json()).to.have.property("service");
});Send → revisá la pestaña Test Results.
Ejercicio 9 — Provocar un 409 real: pedir más stock del que hay
Creá un producto con "stock": 2 (mismo patrón que el Ejercicio 5). Con {{token3}}, armá POST {{base_url_gateway}}/api/v1/orders pidiendo "quantity": 5 de ese producto.
🎯 Qué deberías lograr: 409 Conflict con "detail": "Stock insuficiente" — el pedido no debería aparecer si después llamás GET /api/v1/orders.
💡 ¿Sabías que…? — dónde exacto se corta la petición
OrderService.create() (sección práctica) compara int(product.get("stock", 0)) < data.quantity antes de armar el Order — el pedido nunca llega a intentar guardarse en la base si no hay stock.
python
# ejemplo de referencia — misma idea con otro recurso
if asientos_disponibles < cantidad_pedida:
raise HTTPException(status_code=409, detail="No hay asientos suficientes")Ver solución
En Postman: POST {{base_url_gateway}}/api/v1/orders, Authorization Bearer {{token3}}, Body {"product_id": <id del producto con stock 2>, "quantity": 5}.
Con curl:
json
// 409 Conflict
{ "detail": "Stock insuficiente" }Ejercicio 10 — Pedir un producto que ya desactivaste
Con {{productId}} del Ejercicio 6 (ya desactivado), armá POST {{base_url_gateway}}/api/v1/orders pidiéndolo.
🎯 Qué deberías lograr: 409 Conflict con "detail": "Producto inactivo".
💡 ¿Sabías que…? — el chequeo de "activo" va ANTES que el de stock
En OrderService.create(), if not product.get("active", True) se evalúa antes que el chequeo de stock — un producto inactivo nunca llega a comparar cantidades, corta de una.
Ver solución
En Postman: POST {{base_url_gateway}}/api/v1/orders, body {"product_id": {{productId}}, "quantity": 1}.
json
// 409 Conflict
{ "detail": "Producto inactivo" }Ejercicio 11 — Filtrar productos por stock mínimo
Creá 3 productos con stocks distintos (por ejemplo 0, 5 y 20). Armá GET {{base_url_gateway}}/api/v1/products?minimum_stock=5.
🎯 Qué deberías lograr: la respuesta trae solo los productos con stock >= 5 — el de stock 0 no debería aparecer.
💡 ¿Sabías que…? — este query param ya estaba en el código, sin usarse en Postman
python
def list_products(db: Db, minimum_stock: int | None = Query(default=None, ge=0)):
return repo.get_all(db, minimum_stock)Es el mismo patrón de query param opcional visto en la Clase 6 — ?minimum_stock=10 o nada, los dos caminos son válidos.
Ver solución
En Postman: Method GET, URL {{base_url_gateway}}/api/v1/products, pestaña Params → Key minimum_stock, Value 5.
Con curl:
bash
curl -s "http://127.0.0.1:8000/api/v1/products?minimum_stock=5"Ejercicio 12 — Actualizar un producto con PATCH parcial
Con {{token2}}, armá PATCH {{base_url_gateway}}/api/v1/products/{{productId}} mandando solo {"stock": 100} (sin name ni price).
🎯 Qué deberías lograr: el producto vuelve con stock: 100, pero name y price quedan exactamente igual que antes — un PATCH parcial no toca lo que no le mandás.
💡 ¿Sabías que…? — mismo patrón que `UserUpdate` (sección "El orden para agregar un endpoint nuevo")
ProductUpdate.model_dump(exclude_unset=True) en ProductService.update() solo incluye los campos que realmente vinieron en el body — no los que quedaron en su default de Pydantic. Por eso mandar solo stock no pisa name/price con null.
Ver solución
En Postman: PATCH {{base_url_gateway}}/api/v1/products/{{productId}}, Authorization Bearer {{token2}}, Body {"stock": 100}.
Ejercicio 13 — Aislamiento: un usuario normal solo ve SUS pedidos
Con {{token3}}, creá un pedido de un producto válido (con stock y activo). Después armá GET {{base_url_gateway}}/api/v1/orders primero con {{token3}}, después con {{token2}} (admin).
🎯 Qué deberías lograr: con tu token ves solo TU pedido (una lista corta); con el admin ves esa MISMA lista más los pedidos de todos los demás.
💡 ¿Sabías que…? — no es un `require_role`, es un filtro
list_orders() (routers/orders.py) no bloquea al usuario normal con 403 — lo deja pasar, pero decide qué devolverle: repo.list_for_user(db, user["id"]) si no es admin, repo.list_all(db) si lo es. Es "todos entran, cada quien ve lo suyo" — distinto patrón al de users_service ("o entrás o no").
Ver solución
En Postman: mismo request GET {{base_url_gateway}}/api/v1/orders, cambiando solo el Bearer Token entre {{token3}} y {{token2}} en la pestaña Authorization.
Ejercicio 14 — Intentar consultar el pedido de otra persona
Tomá el id de un pedido creado por el admin ({{token2}}) y armá GET {{base_url_gateway}}/api/v1/orders/{id} con {{token3}} (no admin).
🎯 Qué deberías lograr: 403 Forbidden — a diferencia del Ejercicio 3, este 403 sí es específico de ESE pedido (con otro id tuyo, el mismo request te daría 200).
💡 ¿Sabías que…? — mismo patrón que `get_authorized()` de la sección 2
python
if user["role"] != "admin" and order.user_id != user["id"]:
raise HTTPException(status_code=403, detail="No puede consultar este pedido")Esto es literal lo que ya se documentó en la teoría (service.py) — este ejercicio lo prueba con datos reales en vez de solo leer el código.
Ver solución
En Postman: GET {{base_url_gateway}}/api/v1/orders/{id-de-otro}, Authorization Bearer {{token3}}.
json
// 403 Forbidden
{ "detail": "No puede consultar este pedido" }Ejercicio 15 — Apagar products_service y ver el 503 real, a través del gateway
Con los 4 servicios corriendo, apagá solo products_service (lsof -ti:8002 | xargs kill -9). Con {{token2}}, intentá crear un pedido nuevo por {{base_url_gateway}}.
🎯 Qué deberías lograr: 503 Service Unavailable — y confirmar que el gateway sigue respondiendo bien (/health del gateway sigue en 200), solo orders_service falla porque su dependencia (products_service) está caída.
💡 ¿Sabías que…? — es el `except httpx.RequestError` de la sección 2
products_client.py atrapa específicamente el caso "no hay nadie escuchando en ese puerto" y lo traduce a 503 — no es un error genérico de Python sin manejar, es manejo de errores distribuidos a propósito (sección 2).
Ver solución
En Postman: el mismo request de "Crear un pedido" que ya tenías — con products_service apagado, va a dar 503.
json
// 503 Service Unavailable
{ "detail": "Product Service no disponible" }Reactivá products_service (uvicorn app.main:app --port 8002) antes de seguir.
Ejercicio 16 — Seguir un mismo correlation_id en las 3 terminales
Con el print() que ya agregaste vos mismo en correlation_id_middleware (los 3 main.py), creá un pedido a través del gateway y buscá el mismo X-Correlation-ID en las terminales de api_gateway, orders_service y products_service.
🎯 Qué deberías lograr: el MISMO uuid en las 3 terminales — y confirmar que Postman también lo muestra en la pestaña Headers de la respuesta (X-Correlation-ID).
💡 ¿Sabías que…? — por qué tiene que ser el MISMO en los 3
El gateway lo genera (si no vino ninguno) y lo manda en el header al reenviar; orders_service lo recibe, lo guarda en el pedido, y se lo vuelve a mandar a products_service al pedir el producto (sección 4, diagrama de secuencia). Si ves 3 IDs distintos, algo en la cadena no está propagando el header.
Ver solución
En Postman: request de "Crear un pedido" → pestaña Headers de la respuesta → copiá el X-Correlation-ID. Buscá ese mismo valor con grep en las 3 terminales:
bash
grep "934362c7..." /tmp/gateway.log /tmp/orders.log /tmp/products.logEjercicio 17 — Reto de código: cancelar un pedido
Agregá un endpoint PATCH /api/v1/orders/{id}/cancel que cambie status a "Cancelado" — solo si el pedido es tuyo (o sos admin) y solo si todavía está en "Creado" (no se puede cancelar dos veces). Seguí el mismo orden de capas de la sección "🧱 El orden para agregar un endpoint nuevo".
🎯 Qué deberías lograr: el código nuevo compila (python3 -m py_compile), el servicio arranca, y podés probarlo en Postman: PATCH {{base_url_gateway}}/api/v1/orders/{id}/cancel da 200 la primera vez y 409 la segunda.
💡 ¿Sabías que…? — ya hiciste este mismo patrón con `deactivate()`
ProductService.deactivate() (sección práctica) es literalmente el mismo problema con otro nombre: "cambiar un estado, con una guarda para no repetir la operación". Usalo de referencia, cambiando active: bool por status: str.
Ver solución
python
# service.py
def cancel(self, db: Session, order_id: int, user: dict) -> Order:
order = self.get_authorized(db, order_id, user)
if order.status != "Creado":
raise HTTPException(status_code=409, detail="El pedido no se puede cancelar")
order.status = "Cancelado"
return self.repo.update(db, order) # agregar update() a OrderRepository, mismo patrón que create()
# routers/orders.py
@router.patch("/{order_id}/cancel", response_model=OrderResponse)
def cancel_order(order_id: int, db: Db, user: dict = Depends(get_current_user)):
return service.cancel(db, order_id, user)Probalo en Postman: PATCH {{base_url_gateway}}/api/v1/orders/{{orderId}}/cancel, Authorization Bearer el token del dueño del pedido.
Ejercicio 18 — Reto de código: migración nueva con Alembic
Agregale un campo notes: str | None a Order (models.py). Corré alembic revision --autogenerate -m "agrega notes a orders" y alembic upgrade head. Confirmá con \d orders en psql que la columna se creó.
🎯 Qué deberías lograr: la columna notes aparece en \d orders, y un POST /api/v1/orders sigue funcionando igual (el campo es opcional, no rompe nada existente).
💡 ¿Sabías que…? — son los mismos 3 momentos de la Clase 4/sección 4
① El campo nuevo se registra en Base.metadata apenas lo agregás al modelo (nada toca la base todavía). ② --autogenerate compara y escribe el script de migración. ③ upgrade head lo ejecuta de verdad. Nada de esto pasa solo — cada paso es un comando separado.
Ver solución
python
notes: Mapped[str | None] = mapped_column(String(255), nullable=True)bash
alembic revision --autogenerate -m "agrega notes a orders"
alembic upgrade head
docker exec bd_test_backend psql -U postgres -d orderflow_orders_db -c "\d orders"Ejercicio 19 — Reto de código: cambiar la contraseña
UserUpdate (sección 4) a propósito no deja tocar password desde el PATCH genérico. Diseñá un endpoint aparte, PATCH /api/v1/users/me/password, que reciba current_password y new_password, verifique la actual con verify_password() antes de guardar la nueva con hash_password().
🎯 Qué deberías lograr: en Postman, PATCH {{base_url_gateway}}/api/v1/users/me/password con la contraseña actual correcta da 200; con una contraseña actual incorrecta da 401 — y después de cambiarla, el login con la contraseña VIEJA falla.
💡 ¿Sabías que…? — por qué pedir la contraseña ACTUAL, no solo la nueva
Sin ese chequeo, cualquiera con una sesión ya abierta (un token robado, una compu sin bloquear) podría cambiarle la contraseña a la víctima sin saber la original — pedir la actual confirma que quien cambia la contraseña de verdad la conoce.
python
# ejemplo de referencia — mismo patrón en otro dominio
if not verify_password(data.pin_actual, tarjeta.pin_hash):
raise HTTPException(status_code=401, detail="PIN actual incorrecto")Ver solución
python
class ChangePassword(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=128)
@router.patch("/me/password")
def change_password(data: ChangePassword, db: Db, current: dict = Depends(get_current_user)):
user = repo.get_by_id(db, current["id"])
if not verify_password(data.current_password, user.password_hash):
raise HTTPException(status_code=401, detail="Contraseña actual incorrecta")
user.password_hash = hash_password(data.new_password)
repo.update(db, user)
return {"detail": "Contraseña actualizada"}Probalo en Postman: PATCH {{base_url_gateway}}/api/v1/users/me/password, Authorization Bearer tu token, Body {"current_password":"...","new_password":"..."}.
Ejercicio 20 — Reto final: el mismo flujo, pero en un script Python
Escribí un script (.py, fuera de Postman) que con httpx haga, todo a través del gateway: login → crear producto → crear pedido → listar pedidos — imprimiendo cada respuesta.
🎯 Qué deberías lograr: correr python3 script.py y ver los 3 print() con datos reales — el mismo flujo que ya probaste a mano en Postman, ahora automatizado.
💡 ¿Sabías que…? — httpx.Client (sync) es más simple que AsyncClient acá
products_client.py usa httpx.AsyncClient porque corre DENTRO de un endpoint async def de FastAPI. Un script suelto no tiene ese requisito — httpx.Client() (sin Async) alcanza y es más simple de leer de punta a punta.
Ver solución
python
import httpx
GW = "http://127.0.0.1:8000"
with httpx.Client(base_url=GW) as client:
login = client.post("/api/v1/auth/login", json={
"email": "admin@orderflow.dev", "password": "Admin123*",
})
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
product = client.post("/api/v1/products", headers=headers, json={
"sku": "TEC-999", "name": "Producto de prueba", "price": 10.0, "stock": 5,
}).json()
print("Producto:", product)
order = client.post("/api/v1/orders", headers=headers, json={
"product_id": product["id"], "quantity": 1,
}).json()
print("Pedido:", order)
orders = client.get("/api/v1/orders", headers=headers).json()
print("Todos mis pedidos:", orders)✅ Este script se corrió de verdad contra los 4 servicios — funciona (ver el estado de
orders_serviceen la parte práctica).
❓ Preguntas y respuestas (autoevaluación)
(las preguntas 1-3 tienen su desglose completo de alternativas en 00-Notas/04-Entrevistas.md — eran preguntas de opción múltiple que lanzó el profe en vivo)
1. ¿Qué opción genera menos acoplamiento entre orders_service y products_service: importar el modelo directo, consultar su base de datos, llamar a GET /api/v1/products/{id}, o copiar el servicio entero?
GET /api/v1/products/{id}— llamar al endpoint REST del otro servicio. Es la única opción que se comunica a través de un contrato (la API) en vez de depender de los detalles internos (modelo, esquema de base de datos, código fuente) del servicio ajeno. Ver el desglose completo de las 4 alternativas en Entrevistas → Comunicación entre microservicios.
2. Clasificá cada responsabilidad como del Gateway o de un microservicio: Routing, Correlation ID, calcular el total de un pedido, la regla de stock, validar el token, crear un producto.
Gateway: Routing, Correlation ID y validar el token — son chequeos transversales (aplican a cualquier ruta protegida, sin importar el dominio de negocio). Microservicio: calcular el total y la regla de stock (
orders_service, lógica de negocio) y crear un producto (products_service). Ojo con la sutileza: validar el token (¿es válido?) es del Gateway, pero validar el rol/permiso (¿puede hacer ESTO puntual?) sigue siendo de cada microservicio — ver la nota completa en Entrevistas → Clasificar responsabilidades. 📝 Con el código real deusers_service/security.pyya visto, quien de verdad valida el JWT esusers_service(no el gateway) — ver la aclaración completa en el link.
3. Clasificá cada situación como Autenticación o Autorización: verificar usuario y contraseña, comprobar rol ADMIN, validar token, decidir si puede eliminar.
Autenticación (confirma identidad): verificar usuario y contraseña, validar token. Autorización (decide permisos): comprobar rol ADMIN, decidir si puede eliminar. Regla simple: autenticación responde "¿quién sos?", autorización responde "¿qué podés hacer?" — y siempre pasa primero la autenticación. Ver la tabla completa en Entrevistas → Autenticación vs. Autorización.
4. users_service borra usuarios de verdad (DELETE), pero products_service "desactiva" en vez de borrar (deactivate()). ¿Por qué la diferencia?
Porque un producto puede estar referenciado por pedidos viejos (
orders.product_id) — borrarlo de la tabla dejaría esos pedidos con una referencia rota. Un usuario, en este proyecto, no tiene ninguna tabla que dependa de él, así que borrarlo de verdad es seguro. Es la misma decisión que toma cualquier sistema real con historial: soft delete cuando algo puede estar referenciado, hard delete cuando no.
5. El JWT de este proyecto usa HS256 (simétrico: la misma clave firma y verifica). ¿Por qué orders_service y products_service necesitan el mismo JWT_SECRET que users_service, si ellos nunca hacen login?
Porque con un algoritmo simétrico, la única forma de verificar una firma es tener la misma clave que la creó.
users_servicees el único que emite tokens (create_access_token), pero cualquier servicio con el mismoJWT_SECRETpuede validarlos de forma independiente, sin llamar ausers_serviceen cada request. La alternativa (RS256, asimétrico) evitaría compartir el secreto — cada servicio tendría la clave pública para verificar, pero solousers_servicetendría la privada para firmar. Este proyecto usaHS256porque hay un solo emisor y no hace falta esa separación (ya se mencionó en la sección 4).
6. DELETE /api/v1/users/{id} responde 204 No Content la primera vez. Si lo llamás DE NUEVO con el mismo id, ¿qué código esperás?
404 Not Found— no204otra vez.get_or_404()(service.py) busca el usuario antes de borrar; si ya no existe, corta ahí. Es distinto adeactivate()(products), que sí tiene una guarda explícita para el "ya está así" (409) — acá simplemente no lo encuentra más.
7. En un mismo endpoint pueden aparecer 401, 403 y 404. ¿Cuál es la diferencia de fondo entre los tres?
401 Unauthorized= no sabemos quién sos (falta token, o es inválido/vencido) — autenticación.403 Forbidden= sabemos quién sos, pero no podés hacer ESTO — autorización.404 Not Found= el recurso que pedís no existe (o, enget_authorized()deorders_service, se usa a propósito en vez de403para no confirmarle a un atacante que un pedido ajeno existe). Los tres son sobre "no te dejo pasar", pero por motivos completamente distintos.
8. "Database per service" dice que cada microservicio tiene su propia base — pero los 3 (orderflow_users_db, orderflow_products_db, orderflow_orders_db) viven en el MISMO contenedor Postgres (bd_test_backend). ¿Rompe eso el principio?
No. "Database per service" es sobre aislamiento lógico (cada servicio es dueño exclusivo de su esquema/tablas, nadie más las toca directo), no sobre aislamiento físico (un servidor por base). Un solo Postgres con 3 bases distintas cumple el principio igual que 3 servidores separados — lo que importaría en producción es escalar/respaldar cada una por separado si hiciera falta, no dónde corre el proceso.
9. El X-Correlation-ID aparece en 3 lugares: lo genera el gateway, lo recibe orders_service, y orders_service se lo vuelve a mandar a products_service. Si orders_service generara uno NUEVO en vez de reusar el que le llegó, ¿qué se perdería?
Se perdería la trazabilidad de punta a punta: los logs del gateway tendrían un ID distinto a los de
orders_service/products_service, y ya no se podría seguir "este pedido puntual" en los 3 servicios con una sola búsqueda — quedarían 3 hilos sueltos en vez de uno solo conectado. Por esocorrelation_id_middlewaresiempre chequea primero si el header YA vino (request.headers.get("X-Correlation-ID") or str(uuid4())) antes de inventar uno.
10. alembic revision --autogenerate compara los modelos contra la base real y ESCRIBE un archivo de migración — pero todavía no ejecuta ningún SQL. ¿En qué paso exacto se crea la tabla de verdad en Postgres?
En
alembic upgrade head— ese es el que abre una conexión real (engine_from_configenenv.py) y ejecuta elop.create_table(...)(u otro cambio) dentro de una transacción.--autogeneratesolo lee el estado actual para comparar y generar el script; el archivo que produce es Python, no SQL, y nadie lo corrió todavía en ese paso (mismos 3 momentos que la Clase 4 — ① modelo en RAM, ② escribe el script, ③ lo ejecuta).
📎 Apuntes relacionados
- Clase 6 — Arquitectura de microservicios: de ahí viene el esqueleto del proyecto (
app/config.py,main.py,routers/) queorders_servicereutiliza y extiende con persistencia, seguridad y comunicación entre servicios. - Diagrama: arquitectura del API Gateway (fuente en
04-Recursos/diagramas/clase-07-api-gateway-arquitectura.html). - Diagrama: secuencia de un pedido a través del gateway (fuente en
04-Recursos/diagramas/clase-07-api-gateway-secuencia-pedido.html). - Diagrama: el orden para agregar un endpoint nuevo (fuente en
04-Recursos/diagramas/clase-07-endpoint-nuevo-capas.html). - Diagrama: database per service (users/products/orders) (fuente en
04-Recursos/diagramas/clase-07-database-per-service.html). - Diagrama: reglas de negocio de
OrderService.create()(fuente en04-Recursos/diagramas/clase-07-orderservice-flujo-reglas.html). - Diagrama: crear un pedido, de punta a punta (fuente en
04-Recursos/diagramas/clase-07-ejercicio-crear-pedido-secuencia.html). - Diagrama: modelo de datos de un pedido (User/Order/Product) (fuente en
04-Recursos/diagramas/clase-07-modelo-datos-pedido.html). - Error:
Cannot find reference 'clients' - Entrevistas → Comunicación entre microservicios (Clase 7): pregunta del profe sobre acoplamiento, con las 4 alternativas explicadas.