Python Web Frameworks: arquitectura, middleware, routing
Qué hay dentro de un framework web: la
Applicationcomo grafo de objetos (route table + stack de middleware + sistema de resolución de dependencias + abstracción request/response), cómo un request se despacha internamente hasta tu handler, cómo el router hace matching, cómo se resuelve la inyección de dependencias, cómo elegir framework por requisitos, y cómo el framework integra la seguridad básica (access token, secrets, RBAC, CSRF, service account/IAM).
Teoría
Qué es “un framework por dentro” y por qué importa a nivel senior
Un framework web no es magia: es un grafo de objetos con responsabilidades bien separadas que, entre que llega el request parseado desde el servidor (WSGI/ASGI) y tu función handler, hace siempre lo mismo. A nivel senior la pregunta no es “¿sé usar FastAPI?”, es “¿puedo describir la arquitectura interna de un framework y justificar por qué está partida así?”. Todo framework maduro (Flask, Django, FastAPI/Starlette) se descompone en cinco piezas:
- Application object — el objeto raíz (
Flask(__name__),FastAPI(), elWSGIHandler/ASGIHandlerde Django). Es el callable que el servidor invoca por cada request, y el contenedor que registra todo lo demás: rutas, middleware, config, hooks de ciclo de vida. - Router / route table — la estructura que mapea
(método, path)a un endpoint. Registra rutas al arrancar y las resuelve en cada request. - Middleware stack — la cadena de capas que envuelve el dispatch (modelo “onion”; tratado a fondo en la lección de WSGI/ASGI). Aquí interesa cómo el framework lo registra y lo compone, no el modelo en sí.
- Request/Response abstraction — objetos que envuelven el
environ/scopecrudo en una API ergonómica (request.headers,request.json(),Response(...)). Aíslan tu código del protocolo de transporte. - Sistema de extensión / inyección de dependencias — cómo se conectan recursos transversales (DB session, usuario autenticado, config) a los handlers sin que estos los construyan a mano.
El flujo interno que comparten todos:
servidor (WSGI/ASGI)
│ request cruda (environ / scope+receive+send)
▼
Application.__call__ ← el framework es este callable
│
├─▶ middleware stack (onion): entra por la capa externa
│
├─▶ ROUTER.match(method, path) ──▶ endpoint + path params (o 404/405)
│
├─▶ resolución de dependencias (DI): construir lo que el handler pide
│
├─▶ (de)serialización + validación del body/params
│
├─▶ HANDLER (tu función) ──▶ valor de retorno
│
└─▶ construir Response ──▶ sube por el middleware ──▶ servidor ──▶ cliente
Entender esto permite responder las tres preguntas trampa de una entrevista senior: dónde se decide el 404 (router, antes del handler), cuándo corre la autenticación (middleware o dependencia, antes del handler), y quién construye la DB session (el sistema de DI, no el handler).
El Application object: un callable que es también un registro
La confusión clásica: creer que app “es el servidor”. No. app es el callable de la interfaz (WSGI: app(environ, start_response); ASGI: async app(scope, receive, send)) que el servidor invoca. Su segundo rol es ser el registro central: los decoradores @app.get(...), @app.middleware(...), app.add_middleware(...) no ejecutan nada en el momento — mutan estructuras de datos dentro de app (la route table, la lista de middleware). El trabajo real ocurre en el __call__, una vez por request.
# El decorador de ruta NO llama al handler: lo REGISTRA en la route table.
# En "import time" se construye el grafo; en "request time" se recorre.
app = FastAPI()
@app.get("/orders/{order_id}") # ← registra Route(path, endpoint) en app.router.routes
async def get_order(order_id: int): # ← este cuerpo solo corre cuando llega un request que matchea
...
Esta separación import-time (construcción del grafo) vs request-time (recorrido del grafo) es el modelo mental que hay que tener. Explica por qué mover trabajo pesado a import-time (compilar regex de rutas, construir el árbol de dependencias) hace que el request-time sea barato, y por qué un @app.get mal puesto dentro de una función “no registra nada” si esa función nunca corre.
Routing por dentro: cómo se resuelve (método, path) → endpoint
El router tiene dos momentos: registro (al arrancar) y matching (por request). En el registro, cada ruta se compila: el patrón /orders/{order_id} se convierte en una regex con grupos nombrados y converters que castean ({order_id:int} → matchea \d+ y castea a int). En el matching, el router recibe (method, path) y devuelve (endpoint, path_params) o un fallo (404 si ningún path matchea, 405 si el path matchea pero no el método).
Hay dos estrategias de matching que un senior debe distinguir:
- Lista ordenada de regex (linear scan). El router guarda las rutas en orden de registro; por cada request las prueba una por una hasta el primer match. Es lo que hace Starlette/FastAPI:
routeses una lista, cadaRoutecompila su path a regex, y el dispatch itera devolviendoFULL/PARTIAL(path sí, método no → 405) /NONE. Complejidad O(n) en número de rutas. Simple y predecible; el orden importa (una ruta/{name}declarada antes de/mese traga a/me). - Map/matcher optimizado por especificidad. Flask delega en
werkzeug.routing.Map+Rule: las reglas se compilan y elMapAdaptermatchea considerando peso/especificidad y gestiona redirects (trailing slash) yMethodNotAllowed. Django recorreurlpatterns(URLResolver/URLPattern) resolviendo prefijos anidados víainclude(). Distinto motor, misma idea: patrón → endpoint + kwargs capturados.
# Starlette/FastAPI: el orden de registro decide el match. Ruta específica ANTES de la genérica.
@app.get("/users/me") # ✅ debe ir primero
async def read_me(): ...
@app.get("/users/{user_id}") # si esta va primero, "me" cae aquí como user_id="me" → error de casteo
async def read_user(user_id: int): ...
Puntos senior de routing:
- Mounting / sub-apps.
app.include_router(router, prefix="/v1")(FastAPI),Mount("/admin", app=sub)(Starlette),include("app.urls")(Django) permiten componer routers modulares. Internamente es anidar route tables: el matching entra al sub-router solo si el prefijo matchea. Base de la organización por capas/versiones (/api/v1,/api/v2). - Path params vs query params. Los primeros son parte del patrón (participan en el matching); los segundos no (se parsean del query string después del match). Confundirlos rompe el diseño de recursos.
- Trailing slash. Werkzeug (Flask) redirige
307/308entre/xy/x/; Starlette trata/xy/x/como distintos salvoredirect_slashes. Fuente típica de bugs de integración.
Middleware: cómo el framework lo registra y compone
El modelo onion (cada capa ve el request al bajar y la response al subir) se cubre en profundidad en la lección de WSGI/ASGI. Aquí lo que importa es cómo el framework lo construye internamente: al arrancar, el framework toma la lista de middlewares registrados y los anida como funciones que se envuelven una a otra, produciendo un único callable. Ese anidamiento ocurre una sola vez (import-time), no por request.
# Django construye la cadena UNA vez: cada middleware es una factory get_response -> handler.
# El resultado es f_ext(f_mid(f_int(view))) — se compone al arrancar, se llama por request.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", # capa más externa
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
]
# FastAPI/Starlette: add_middleware apila; el ÚLTIMO agregado queda MÁS EXTERNO.
app.add_middleware(GZipMiddleware) # se ejecuta primero al bajar, último al subir
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"])
Distinción senior que se pregunta: middleware vs dependencia. Un middleware es transversal a todas las rutas y opera sobre el request/response crudo (CORS, gzip, request-id, tracing). Una dependencia es selectiva por endpoint y opera sobre valores ya parseados (obtener el usuario autenticado, abrir una DB session). Regla: si aplica a todo y no necesita el modelo de datos parseado → middleware; si aplica a algunos endpoints y produce un valor que el handler consume → dependencia. Meter auth por-endpoint en un middleware global es un olor de diseño (obliga a listas de exclusión frágiles).
Inyección de dependencias: el diferenciador arquitectónico
La inyección de dependencias (DI) es “el handler declara qué necesita, no cómo construirlo; el framework lo provee”. Es lo que separa un handler testeable y desacoplado de uno que instancia su propia DB session y su propio cliente HTTP a mano (imposible de mockear, acoplado a la infraestructura).
FastAPI es el caso más explícito en Python: la DI se hace por la firma de la función. Declaras un parámetro = Depends(callable) y el framework, en request-time:
- Inspecciona la firma del handler y descubre el árbol de dependencias (una dependencia puede pedir otras → sub-dependencias, un DAG).
- Resuelve en orden topológico, llamando cada dependencia (y cacheando su resultado dentro del request: la misma dependencia usada en tres sitios se ejecuta una vez por request,
use_cache=Truepor defecto). - Inyecta los resultados como argumentos y llama al handler.
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
app = FastAPI()
# Dependencia con setup/teardown vía yield: lo de ANTES del yield es setup,
# lo de DESPUÉS corre cuando la response ya se envió (cierre garantizado, como un context manager).
async def get_db() -> AsyncSession:
session = SessionLocal()
try:
yield session # ← el valor inyectado
finally:
await session.close() # ← teardown post-response
# Sub-dependencia: get_current_user DEPENDE de get_db. FastAPI resuelve el DAG.
async def get_current_user(db: AsyncSession = Depends(get_db)) -> User:
...
@app.get("/orders")
async def list_orders(
user: User = Depends(get_current_user), # el handler solo declara QUÉ necesita
db: AsyncSession = Depends(get_db), # get_db se resuelve UNA vez y se comparte (cache)
):
return await orders_repo.for_user(db, user.id)
Propiedades senior de este modelo:
- Scope de request. Cada dependencia se resuelve por request y su cache vive solo ese request. No es un singleton de aplicación (eso sería un recurso global, p. ej. un pool de conexiones creado en el
lifespan). - Teardown con
yield. Convierte la dependencia en un context manager: ideal para transacciones (commit/rollback), conexiones, locks. El teardown corre después de enviar la response, en orden inverso. - Override para testing.
app.dependency_overrides[get_db] = fake_dbsustituye la dependencia real en tests sin tocar el código de producción. Este es el argumento de por qué DI existe: testeabilidad. - Contraste con el DI clásico (IoC container). En Java/.NET el contenedor inyecta por constructor y suele ser singleton/scoped configurado aparte. FastAPI inyecta por firma de función y es request-scoped por defecto — más ligero, menos ceremonia, pero sin un contenedor central que puedas inspeccionar. Flask y Django no traen DI: usan service locators (
current_app,g,requestcomo proxies context-local;django.conf.settings) — más simple, pero el acoplamiento es implícito y el mock es global, no por-request.
Cómo despacha cada framework (comparación interna)
| Pieza | Flask | Django | FastAPI (sobre Starlette) |
|---|---|---|---|
| Callable base | WSGI app(environ, start_response) | WSGI/ASGI handler | ASGI app(scope, receive, send) |
| Router | Werkzeug Map/Rule (matcher por especificidad) | URLResolver sobre urlpatterns (regex/route) | lista ordenada de Route (regex, linear scan) |
| Estado por request | request/g como proxies context-local (ContextVar) | objeto HttpRequest pasado explícito | objeto Request + DI por firma |
| DI | no built-in (extensiones + g) | no built-in (settings + apps registry) | Depends, DAG, cache, yield, overrides |
| Validación/serialización | manual (o extensión) | Forms / DRF serializers | Pydantic integrado en la firma |
Insight: FastAPI = Starlette (toolkit ASGI: routing + middleware + request/response) + Pydantic (validación/serialización) + un sistema de DI. No reinventa el servidor ni la interfaz; ensambla piezas. Saber esto responde “¿qué es FastAPI internamente?”: una capa de DI + validación por firma encima del toolkit ASGI Starlette.
Patrones de capas: dónde encaja la DI
El anti-patrón de junior es el fat handler: el endpoint parsea, valida reglas de negocio, arma SQL y serializa, todo en una función. No se testea sin levantar HTTP+DB, y la lógica no se reutiliza. La arquitectura senior separa responsabilidades en capas, y la DI es el pegamento entre ellas:
Router / handler (presentación) → traduce HTTP ↔ dominio; delgado. Sin reglas de negocio.
│ (DI inyecta el service)
Service (dominio / casos de uso) → orquesta reglas de negocio; no sabe de HTTP ni de SQL.
│ (DI inyecta el repository)
Repository (acceso a datos) → traduce dominio ↔ persistencia; el único que toca la DB.
# Handler DELGADO: solo traduce HTTP y delega. La DI le entrega el service ya construido.
@app.post("/orders", status_code=201)
async def create_order(
body: OrderCreate,
service: OrderService = Depends(get_order_service), # DI construye service (que trae su repo)
):
order = await service.place_order(body) # toda la regla de negocio vive aquí, testeable sin HTTP
return OrderResponse.model_validate(order)
Beneficio concreto: el OrderService se testea unitariamente con un repo falso, sin cliente HTTP ni DB. El handler se testea con dependency_overrides. Cada capa se prueba en aislamiento — imposible con el fat handler.
Cómo seleccionar el framework según requisitos
No hay “el mejor framework”: hay el que ajusta a los requisitos y restricciones. Ejes de decisión senior:
| Eje | Flask | Django | FastAPI |
|---|---|---|---|
| Filosofía | micro, ensambla tú | batteries-included | micro + validación/DI |
| Concurrencia | WSGI sync (async parcial) | WSGI/ASGI (async creciente) | ASGI async nativo |
| ORM incluido | no (SQLAlchemy aparte) | sí (Django ORM) | no (SQLAlchemy/otro) |
| Admin / auth / migraciones | no (extensiones) | sí (admin, auth, migrations) | no |
| Validación / OpenAPI | manual | DRF (con esfuerzo) | Pydantic + OpenAPI auto |
| Curva | baja | media-alta | baja-media |
Mapeo requisito → elección:
- CRUD grande con back-office, usuarios, permisos, admin ya-hecho, time-to-market corto → Django. Traer todo eso a mano en Flask/FastAPI es reinventar Django peor.
- API JSON async, alta concurrencia I/O-bound, validación estricta y OpenAPI automático, microservicios → FastAPI. Su DI + Pydantic + async encajan con backends modernos de servicios.
- Servicio pequeño, control total del stack, sin querer un framework opinado, o integrar en algo existente → Flask. Máxima flexibilidad, mínima imposición.
- CPU-bound puro (no I/O) → el framework casi da igual; lo que decide es el modelo de procesos/workers, no async (async no acelera CPU-bound).
La decisión es arquitectónica y cara de revertir (condiciona ORM, modelo de concurrencia, ecosistema, contratación). Se justifica con requisitos, no con moda. Regla: elige por lo que el proyecto necesita en 12 meses, no por el benchmark de “hello world”.
Seguridad básica integrada en el framework
El framework es donde se cablean los controles de seguridad. La profundidad de OWASP (XSS/SQLi/CSRF a fondo) vive en otra lección; aquí interesa cómo el framework integra cada control.
Access token (Bearer / OAuth2). El patrón integrado es una dependencia de seguridad: extrae el token del header Authorization: Bearer <jwt>, lo valida (firma + expiración), y devuelve el principal (usuario). Un token que falla → 401 con WWW-Authenticate: Bearer. Al ser una dependencia, se compone (RBAC la usa como sub-dependencia) y aparece en el OpenAPI como esquema de seguridad.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # declara el esquema; extrae el Bearer
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
try:
payload = jwt.decode(token, SETTINGS.jwt_public_key, algorithms=["RS256"])
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"}, # requerido por el estándar en 401 Bearer
)
return await users_repo.get(payload["sub"])
Secrets manager. El principio: los secretos nunca en el código ni en el repo. El framework los recibe por configuración inyectable. En dev, variables de entorno (.env); en prod, un secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) del que la app lee al arrancar. pydantic-settings es el patrón idiomático: una BaseSettings que carga de env y valida tipos, inyectable como dependencia.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env") # dev: .env · prod: env inyectada por el secrets manager
database_url: str
jwt_public_key: str
# NUNCA un default con el valor real del secreto en el código
SETTINGS = Settings() # falla al arrancar si falta un secreto → fail-fast, no en el primer request
Diferencia local vs prod (pregunta A3): mismo código, distinta fuente de config — en dev el .env local; en prod la variable la inyecta el orquestador/secrets manager. El código no cambia; cambia de dónde vienen los valores. Nunca commitear el .env.
Roles / RBAC. La autorización (¿puede este usuario hacer esto?) se implementa como dependencia que compone sobre la autenticación (¿quién es?). Falla de permiso → 403 (distinto de 401: 401 = no sé quién eres; 403 = sé quién eres y no puedes). FastAPI ofrece Security + SecurityScopes para RBAC por scopes; Django trae un sistema de permissions y grupos.
def require_role(role: str):
# factory de dependencia: devuelve una dependencia que exige un rol
async def checker(user: User = Depends(get_current_user)) -> User:
if role not in user.roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return checker
@app.delete("/orders/{order_id}", status_code=204)
async def delete_order(order_id: int, user: User = Depends(require_role("admin"))):
... # solo llega aquí si el token es válido (401 si no) Y el rol es admin (403 si no)
CSRF. Relevante solo con autenticación por cookie de sesión (credencial ambient que el browser adjunta sola en requests cross-site). Con Bearer token en header no hay CSRF: el token no se envía solo, y JS de otro origin no puede leerlo (Same-Origin Policy) — por eso las APIs stateless son inmunes por diseño. Django integra CsrfViewMiddleware (token por sesión) para su modelo cookie-based; Starlette/FastAPI no lo traen porque su default es Bearer. Regla: si usas cookie de sesión, activa CSRF y pon HttpOnly+Secure+SameSite; si usas Bearer, el problema no existe. (Detalle del mecanismo en la lección de seguridad OWASP.)
Service account / IAM. Distinto de la auth de usuario: es cómo la app misma se autentica ante otros servicios (DB gestionada, bucket, otra API, un cloud). El anti-patrón es una service account key (archivo JSON con credenciales de larga vida) commiteada o rotada a mano. El patrón senior es workload identity / IAM roles: el runtime (pod de k8s, tarea ECS, Cloud Run) asume una identidad IAM y obtiene credenciales temporales y auto-rotadas del proveedor, sin secreto persistente. Principio transversal: least privilege — la identidad de la app solo tiene los permisos que ese servicio necesita, nada más. Así, aunque la app se comprometa, el radio de daño es mínimo.
Ejercicios
1. Ordenar rutas para que el matching sea correcto
Tienes estas rutas en un router de linear-scan (Starlette/FastAPI). Un GET a /users/me responde un error de validación en vez del perfil propio. ¿Por qué, y cómo lo arreglas?
@app.get("/users/{user_id}")
async def read_user(user_id: int): ...
@app.get("/users/me")
async def read_me(): ...
Solución
El router prueba las rutas en orden de registro y se queda con el primer match. /users/{user_id} está registrada primero y matchea /users/me, capturando user_id="me"; al castear a int falla → error de validación (422). La ruta específica nunca se alcanza.
Arreglo: registrar la ruta específica antes que la genérica.
@app.get("/users/me") # ✅ específica primero
async def read_me(): ...
@app.get("/users/{user_id}") # genérica después
async def read_user(user_id: int): ...
Lección senior: en routers de linear-scan el orden es semántica. En routers por especificidad (Werkzeug) el motor prioriza la regla más específica automáticamente, pero no asumas eso en Starlette. Regla general: rutas literales antes que las paramétricas.
2. Contar cuántas veces se ejecuta una dependencia por request
En FastAPI, get_db se usa en el handler y también en dos sub-dependencias que el handler pide. ¿Cuántas sesiones de DB se abren por request? ¿Y si pusieras Depends(get_db, use_cache=False) en un sitio?
async def get_db(): ... # yield session
async def get_user(db = Depends(get_db)): ...
async def get_tenant(db = Depends(get_db)): ...
@app.get("/x")
async def h(u = Depends(get_user), t = Depends(get_tenant), db = Depends(get_db)): ...
Solución
Una sola sesión. Por defecto use_cache=True: dentro de un mismo request, FastAPI cachea el resultado de cada dependencia por su cache key (la función + parámetros). get_db aparece 3 veces (vía get_user, get_tenant y directo en el handler) pero se resuelve una vez y las tres referencias comparten la misma session. El teardown (finally: close) corre una vez, tras la response.
Si pones use_cache=False en un sitio, esa referencia fuerza una resolución nueva → se abre una segunda sesión independiente para ese punto, con su propio teardown. Útil solo si necesitas explícitamente un recurso aislado (p. ej. una transacción separada). Casi siempre es un error que duplica conexiones.
Lección: el scope de cache de una dependencia es el request, no la app. Compartir la session dentro del request es lo que permite que service y repo trabajen en la misma transacción.
3. Refactorizar un fat handler a capas con DI
Este handler mezcla HTTP, regla de negocio y SQL. Sepáralo en handler / service / repository e indica qué inyecta la DI.
@app.post("/orders")
async def create_order(body: OrderCreate, db = Depends(get_db)):
if body.total <= 0:
raise HTTPException(400, "Invalid total")
row = await db.execute(insert(orders).values(**body.dict()))
await db.commit()
await email_client.send(body.customer_email, "Order placed")
return {"id": row.inserted_primary_key[0]}
Solución
# --- Repository: único que toca la DB ---
class OrderRepository:
def __init__(self, db: AsyncSession): self.db = db
async def insert(self, data: OrderCreate) -> Order:
row = await self.db.execute(insert(orders).values(**data.model_dump()))
await self.db.commit()
return Order(id=row.inserted_primary_key[0], **data.model_dump())
# --- Service: reglas de negocio, sin HTTP ni SQL directo ---
class OrderService:
def __init__(self, repo: OrderRepository, mailer: Mailer):
self.repo, self.mailer = repo, mailer
async def place_order(self, data: OrderCreate) -> Order:
if data.total <= 0:
raise DomainError("Invalid total") # error de dominio, NO HTTPException
order = await self.repo.insert(data)
await self.mailer.send(data.customer_email, "Order placed")
return order
# --- DI: construye la cadena repo→service ---
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(OrderRepository(db), Mailer())
# --- Handler: delgado, solo traduce HTTP ↔ dominio ---
@app.post("/orders", status_code=201)
async def create_order(body: OrderCreate, service: OrderService = Depends(get_order_service)):
try:
order = await service.place_order(body)
except DomainError as e:
raise HTTPException(status_code=422, detail=str(e)) # traducción dominio→HTTP en el borde
return OrderResponse.model_validate(order)
Claves: el service no lanza HTTPException (no sabe de HTTP; lanza un error de dominio que el handler traduce). La DI inyecta service ya armado con su repo y su mailer. OrderService se testea con un repo y un mailer falsos, sin HTTP ni DB.
4. Decidir el framework por requisitos
Para cada caso elige Flask, Django o FastAPI y justifica en una frase: (a) plataforma interna de gestión con panel admin, roles y reportes, entrega en 6 semanas; (b) API pública de alto tráfico que hace fan-out a 5 microservicios por request; (c) webhook receiver de 3 endpoints que se integra en una app Flask existente.
Solución
- (a) Django. El admin, el sistema de auth/permisos y las migraciones ya hechos ahorran semanas; el back-office CRUD es exactamente su punto dulce. Construir eso en FastAPI sería reimplementar Django peor.
- (b) FastAPI. Fan-out a 5 servicios por request es I/O-bound de alta concurrencia: ASGI async atiende muchas conexiones concurrentes por proceso con pocos workers. Pydantic + OpenAPI automático encajan con una API pública que debe documentar su contrato. (Requisito duro: todo el path async, sin llamadas bloqueantes.)
- (c) Flask. Ya hay una app Flask; añadir 3 endpoints ahí evita introducir un segundo framework, un segundo runtime y complejidad de despliegue. La coherencia del stack pesa más que las features de FastAPI para 3 rutas simples.
Lección: la elección se justifica con requisitos (features necesarias, modelo de concurrencia, coste de integración), no con “cuál es más rápido/moderno”.
5. Elegir el control de seguridad y el status correcto
Para cada situación di qué control usar y qué status devolver: (a) request sin header Authorization; (b) token válido pero el usuario no es admin y pide borrar un recurso; (c) API stateless con Bearer — ¿hace falta protección CSRF?; (d) la app necesita leer credenciales de la DB en producción.
Solución
- (a)
401 UnauthorizedconWWW-Authenticate: Bearer. No hay principal: la autenticación falla. No es 403 (403 implica que sí sé quién eres). - (b)
403 Forbidden. La autenticación pasó (sé quién es), falla la autorización/RBAC. Se implementa como dependencia que compone sobreget_current_usery chequea el rol. - (c) No. CSRF explota credenciales ambient (cookies que el browser adjunta solo). Un Bearer en header no se envía automáticamente y JS cross-origin no puede leerlo (SOP) → inmune por diseño. CSRF solo aplica si usas cookie de sesión.
- (d) Secrets manager + IAM. Los secretos no van en el código: se inyectan por config (
pydantic-settingsdesde env), y en prod la fuente es un secrets manager. Mejor aún, la app asume una identidad IAM/workload identity con credenciales temporales de least-privilege en vez de una key de service account de larga vida.
Preguntas tipo entrevista (EN)
1. “What actually happens inside a web framework between the server handing off a parsed request and your handler function running?”
- ❌ Wrong/trap: “The framework receives the HTTP bytes and calls my function.” — Conflates the server with the framework and skips every internal stage. The framework does not parse raw HTTP (that’s the WSGI/ASGI server) and it does not call the handler directly.
- ✅ Correct: The framework’s application callable is invoked with the already-parsed request (WSGI
environ/ ASGIscope). It flows through the middleware stack (onion), the router matches(method, path)to an endpoint (or returns 404/405), the dependency system builds what the handler declared, the body/params are validated/deserialized, then the handler runs and its return value is turned into a Response that travels back up. - ⭐ Optimal: Adds the import-time vs request-time distinction — routes and the middleware chain are assembled once at startup (decorators mutate the app’s route table; middlewares are nested into one callable), and only traversed per request. That’s why 404 is decided at the router before the handler, and why auth via a dependency runs before the handler but after routing.
2. “How does FastAPI’s dependency injection work, and how is it different from a classic IoC container?”
- ❌ Wrong/trap: “It’s a global container where you register services and it injects singletons.” — That’s the Java/.NET model, not FastAPI. FastAPI has no central container and dependencies are not singletons by default.
- ✅ Correct: You declare
param = Depends(callable)in the function signature. Per request, FastAPI inspects the signature, builds the dependency DAG (dependencies can depend on other dependencies), resolves it topologically, caches each dependency’s result within that request (same dependency used in several places runs once), injects the results, and calls the handler.yielddependencies give setup/teardown;dependency_overridesswaps them in tests. - ⭐ Optimal: Contrasts scope and mechanism: classic IoC injects by constructor, is configured in a central container, and is typically singleton/scoped; FastAPI injects by signature, is request-scoped by default, and needs no container — lighter, but there’s no single registry to introspect. Notes that Flask/Django have no DI and rely on service locators (
current_app/g,settings), where mocking is global rather than per-request.
3. “Why put authentication in a dependency instead of in a global middleware?”
- ❌ Wrong/trap: “Middleware is faster, so always authenticate there.” — Performance isn’t the axis, and global auth middleware forces brittle exclusion lists for public routes.
- ✅ Correct: Middleware is transversal to all routes and works on the raw request/response (CORS, gzip, tracing, request-id). A dependency is selective per endpoint and produces a value the handler consumes (the current user, a DB session). Auth is usually per-endpoint and yields a principal, so it belongs in a dependency; it also composes (RBAC uses it as a sub-dependency) and shows up in the OpenAPI security scheme.
- ⭐ Optimal: Gives the decision rule — applies to everything and needs only the raw request → middleware; applies to some routes and yields a parsed value → dependency — and notes the failure mode of global auth middleware: you end up maintaining allow/deny path lists that silently break when routes change.
4. “How do you select between Flask, Django, and FastAPI for a new service?”
- ❌ Wrong/trap: “FastAPI, it’s the fastest and most modern.” — A benchmark answer that ignores requirements; for a CRUD back-office it’s the wrong call.
- ✅ Correct: Match to requirements. Django when you need batteries-included (admin, auth, ORM, migrations) and fast time-to-market for CRUD/back-office. FastAPI for async JSON APIs, high I/O-bound concurrency, strict validation and auto OpenAPI, microservices. Flask for small services, full control of the stack, or integrating into an existing Flask app.
- ⭐ Optimal: Frames it as an architectural, hard-to-reverse decision (it locks the ORM, concurrency model, ecosystem, hiring) chosen for the next 12 months, not “hello world” throughput — and notes that for CPU-bound work the framework barely matters; the process/worker model does, since async doesn’t speed up CPU-bound.
5. “Where do you store secrets, and how does that differ between local and production?”
- ❌ Wrong/trap: “In a config file / a
.envcommitted to the repo, or hardcoded defaults so it just works.” — Committing secrets or hardcoding them is the vulnerability itself. - ✅ Correct: Secrets never live in code or the repo. They’re injected via configuration (e.g.
pydantic-settingsreading env vars, validated and fail-fast at startup). Locally the source is a non-committed.env; in production the source is a secrets manager (AWS/GCP Secret Manager, Vault). Same code, different source of the values. - ⭐ Optimal: Extends to machine identity: prefer workload identity / IAM roles so the runtime obtains short-lived, auto-rotated credentials instead of a long-lived service-account key, all under least privilege, so a compromise has minimal blast radius.
6. “Does a stateless API with Bearer tokens need CSRF protection? Why or why not?”
- ❌ Wrong/trap: “Yes, always add CSRF tokens to every API.” — Adds ceremony that does nothing for a Bearer API and misdiagnoses the threat.
- ✅ Correct: No. CSRF exploits ambient credentials — cookies the browser attaches automatically on cross-site requests. A Bearer token in the
Authorizationheader is not sent automatically, and cross-origin JS can’t read or set it (Same-Origin Policy), so there’s nothing to forge. CSRF only applies when auth is cookie/session-based. - ⭐ Optimal: States the rule both ways — cookie session → enable CSRF and set
HttpOnly+Secure+SameSite; Bearer in header → immune by design — and notes this is exactly why Django shipsCsrfViewMiddleware(cookie-based default) while Starlette/FastAPI don’t (Bearer default).
7. “Two routes, /users/me and /users/{user_id}, and /users/me returns a validation error. What’s wrong?”
- ❌ Wrong/trap: “The framework should know
meisn’t a number and skip that route.” — Assumes the router backtracks on cast failure; a linear-scan router doesn’t. - ✅ Correct: In a linear-scan router (Starlette/FastAPI) routes are tried in registration order, first match wins.
/users/{user_id}is registered first, matches/users/me, capturesuser_id="me", and theintcast fails → 422. Fix: register the literal route before the parametric one. - ⭐ Optimal: Generalizes: in linear-scan routers order is semantics (literal before parametric); specificity-based routers (Werkzeug’s
Map) rank the more specific rule automatically, so the same bug may not appear — but you shouldn’t rely on that in Starlette. Mentions the related 404-vs-405 distinction the router makes (path miss vs method miss).
Referencias
- FastAPI — Dependencies, Security (OAuth2, scopes), Bigger Applications (routers): docs oficiales de FastAPI.
- Starlette — Routing, Middleware, Requests/Responses (el toolkit ASGI bajo FastAPI): docs oficiales de Starlette.
- Flask — Application/Request context,
werkzeug.routing(Map/Rule): docs oficiales de Flask y Werkzeug. - Django — URL dispatcher, Middleware, Request/Response cycle, permissions/CSRF: docs oficiales de Django.
- Pydantic Settings — gestión de configuración/secretos vía
BaseSettings. - OWASP — Authentication, Authorization, CSRF, Secrets Management Cheat Sheets (profundidad en la lección de seguridad OWASP).
- Cloud IAM / workload identity — docs de AWS IAM roles, GCP Workload Identity (credenciales temporales, least privilege).