Module 3: Request and Response
Proyecto: API de Productos con Filtros y Paginación
Descripción del proyecto
Integras todo lo aprendido en el Módulo 3: construirás una API de productos que combina path parameters, query parameters y request body. Incluye filtros por categoría, rango de precios, búsqueda por nombre, ordenamiento, paginación, y los endpoints CRUD típicos. Este proyecto extiende la base del Módulo 2 y prepara el terreno para Pydantic (Módulo 4) y HTTPException (Módulo 5).
Este proyecto demuestra el verdadero poder de los parámetros de request: en una sola ruta GET /products podrás filtrar por categoría, acotar por rango de precios, buscar por texto en el nombre, ordenar por cualquier campo y paginar los resultados. Es el mismo patrón que usan APIs de producción como Stripe, Shopify o cualquier e-commerce.
La combinación de path params (para identificar el recurso), query params (para filtrar y paginar) y request body (para enviar datos en POST/PUT/PATCH) es el ABC de cualquier API REST. Dominar esta estructura te permite diseñar endpoints predecibles y fáciles de consumir desde frontend, móvil o integraciones externas.
Antes de empezar
Asegúrate de haber completado las cápsulas 02 a 05 del Módulo 3. Necesitas dominar:
- Cápsula 02 — Path parameters: Type hints para validación automática, manejo de 422 cuando el path param es inválido, orden de rutas (estáticas antes de dinámicas)
- Cápsula 03 — Query parameters: Parámetros opcionales con defaults,
Optional,skipylimitpara paginación - Cápsula 04 — Request body:
Body(...)para recibir JSON en POST/PUT/PATCH,Content-Type: application/json - Cápsula 05 — Headers y response: Status codes en decoradores,
JSONResponsepara respuestas con código personalizado
Si tu proyecto del Módulo 2 (CRUD básico) funciona y puedes arrancarlo con uvicorn app.main:app --reload, tienes la base. Este proyecto extiende ese CRUD agregando filtros y paginación al listado.
Objetivos
Al completar este proyecto:
- API de productos con GET (list + detail), POST, PUT, PATCH, DELETE
- Filtros por categoría, rango de precios (
min_price,max_price), búsqueda por nombre (search) - Ordenamiento (
sort_by,order) y paginación (skip,limit) - Combinación de path + query + body en los endpoints
- Status codes apropiados (200, 201, 404)
- Rúbrica de evaluación y checklist de completitud
Estructura del proyecto
fastapi-fundamentals/
├── venv/
├── app/
│ ├── __init__.py
│ └── main.py
├── requirements.txt
└── .gitignore
Ejecuta: uvicorn app.main:app --reload
Modelo de datos
Cada producto tiene:
| Campo | Tipo | Descripción |
|---|---|---|
id | int | Identificador único |
name | str | Nombre del producto |
category | str | Categoría (Electrónica, Hogar, Deportes, etc.) |
price | float | Precio |
in_stock | bool | Disponibilidad |
Datos de ejemplo (6 productos precargados)
[
{"id": 1, "name": "Laptop", "category": "Electrónica", "price": 999.99, "in_stock": true},
{"id": 2, "name": "Mouse", "category": "Accesorios", "price": 29.99, "in_stock": true},
{"id": 3, "name": "Teclado", "category": "Accesorios", "price": 79.99, "in_stock": true},
{"id": 4, "name": "Monitor", "category": "Electrónica", "price": 299.99, "in_stock": true},
{"id": 5, "name": "Webcam", "category": "Accesorios", "price": 89.99, "in_stock": false},
{"id": 6, "name": "Auriculares", "category": "Accesorios", "price": 49.99, "in_stock": true}
]
Guía paso a paso
Paso 1: Setup con datos de productos
Crea la estructura del proyecto y la lista inicial de productos. Define las funciones auxiliares find_product y generate_id que reutilizarás en varios endpoints.
from typing import Optional
from fastapi import FastAPI, Body
from fastapi.responses import JSONResponse
app = FastAPI(
title="Products API - Módulo 3",
description="API de productos con filtros, paginación y CRUD.",
version="1.0.0",
)
products = [
{"id": 1, "name": "Laptop", "category": "Electrónica", "price": 999.99, "in_stock": True},
{"id": 2, "name": "Mouse", "category": "Accesorios", "price": 29.99, "in_stock": True},
{"id": 3, "name": "Teclado", "category": "Accesorios", "price": 79.99, "in_stock": True},
{"id": 4, "name": "Monitor", "category": "Electrónica", "price": 299.99, "in_stock": True},
{"id": 5, "name": "Webcam", "category": "Accesorios", "price": 89.99, "in_stock": False},
{"id": 6, "name": "Auriculares", "category": "Accesorios", "price": 49.99, "in_stock": True},
]
def find_product(product_id: int):
return next((p for p in products if p["id"] == product_id), None)
def generate_id():
return max((p["id"] for p in products), default=0) + 1
@app.get("/")
def root():
return {"service": "Products API", "version": "1.0.0", "total_products": len(products)}
Explicación: La raíz devuelve metadata del servicio. find_product usa next() con un generador para buscar por ID; si no encuentra, retorna None. generate_id() evita duplicados al usar el máximo existente + 1.
Verifica en /docs: Abre http://127.0.0.1:8000/docs, ejecuta GET / y comprueba que devuelve total_products: 6.
Paso 2: GET /products — Lista todos
Implementa el endpoint base sin filtros. Por ahora devuelve la lista completa con la estructura {total, skip, limit, items} para preparar la paginación.
@app.get("/products")
def list_products(skip: int = 0, limit: int = 10):
total = len(products)
paginated = products[skip : skip + limit]
return {"total": total, "skip": skip, "limit": limit, "items": paginated}
Explicación: skip y limit son query params con valor por defecto. El slicing products[skip : skip + limit] extrae la "página" actual.
Verifica en /docs: GET /products debe retornar 6 items. Prueba ?skip=2&limit=2 y comprueba que devuelve solo 2 items con total: 6.
Paso 3: GET /products/{id} — Path parameter
Añade el endpoint para obtener un producto por ID. Usa path parameter y maneja el caso 404 con JSONResponse.
@app.get("/products/{product_id}")
def get_product(product_id: int):
product = find_product(product_id)
if product is None:
return JSONResponse(status_code=404, content={"error": "Product not found"})
return product
Explicación: FastAPI convierte product_id de la URL a int automáticamente. Si es inválido (ej. /products/abc), retorna 422 sin ejecutar la función.
Verifica en /docs: GET /products/1 devuelve el Laptop. GET /products/99 devuelve 404 con body {"error": "Product not found"}.
Paso 4: Query params — Filtro por categoría y rango de precios
Extiende list_products con category, min_price y max_price. Los filtros se aplican en secuencia: primero filtrar, luego ordenar, luego paginar.
@app.get("/products")
def list_products(
category: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
skip: int = 0,
limit: int = 10,
):
result = list(products)
if category:
result = [p for p in result if p.get("category") == category]
if min_price is not None:
result = [p for p in result if p.get("price", 0) >= min_price]
if max_price is not None:
result = [p for p in result if p.get("price", float("inf")) <= max_price]
total = len(result)
paginated = result[skip : skip + limit]
return {"total": total, "skip": skip, "limit": limit, "items": paginated}
Explicación: Usamos if min_price is not None (no if min_price) porque 0 es un valor válido. Para category usamos if category: porque cadena vacía suele indicar "sin filtro".
Verifica en /docs: Prueba ?category=Accesorios (debe devolver 4 productos), ?min_price=50&max_price=100 (Teclado, Webcam, Auriculares).
Paso 5: Búsqueda por nombre (case insensitive)
Añade el query param search para filtrar productos cuyo nombre contenga el texto, sin distinguir mayúsculas/minúsculas.
# Dentro de list_products, después de los filtros de category/precio y antes del total:
search: Optional[str] = None, # añadir al signature
if search and search.strip():
search_lower = search.lower().strip()
result = [p for p in result if search_lower in p.get("name", "").lower()]
La firma completa queda:
def list_products(
category: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
search: Optional[str] = None,
sort_by: str = "id",
order: str = "asc",
skip: int = 0,
limit: int = 10,
):
Explicación: .strip() evita búsquedas con espacios vacíos. La comparación search_lower in ... lower() hace la búsqueda insensible a mayúsculas.
Verifica en /docs: ?search=teclado o ?search=TECLADO deben devolver el Teclado. ?search=lap debe devolver la Laptop.
Paso 6: Ordenamiento (sort_by, order)
Añade ordenamiento por id, name o price, en orden ascendente o descendente.
if sort_by in ("id", "name", "price"):
reverse = order.lower() == "desc"
result.sort(key=lambda p: p.get(sort_by, ""), reverse=reverse)
Explicación: Validar sort_by in (...) evita KeyError si envían un campo inexistente. order.lower() == "desc" normaliza el valor.
Verifica en /docs: ?sort_by=price&order=desc debe listar primero la Laptop (999.99) y al final el Mouse (29.99).
Paso 7: Paginación (skip, limit)
La paginación ya está integrada. Asegúrate de aplicar skip y limit después de todos los filtros y del ordenamiento, y de incluir total en la respuesta para que el cliente conozca el total de registros.
total = len(result)
paginated = result[skip : skip + limit]
return {"total": total, "skip": skip, "limit": limit, "items": paginated}
Verifica en /docs: ?skip=2&limit=2 con category=Accesorios debe devolver items 3 y 4 de los 4 accesorios, con total: 4.
Combinación de filtros
Todos los filtros pueden usarse juntos. El orden de aplicación recomendado es:
- Filtro por categoría — Restringe al conjunto de categoría
- Filtro por min_price / max_price — Acota por rango de precios
- Búsqueda por nombre — Filtra por texto en el nombre
- Ordenamiento — Ordena el resultado filtrado
- Paginación — Aplica skip/limit al resultado final
Ejemplo de URL con todos los filtros:
GET /products?category=Electrónica&min_price=100&max_price=500&search=mon&sort_by=price&order=asc&skip=0&limit=5
Esta petición devuelve productos de Electrónica entre 100 y 500, cuyo nombre contenga "mon", ordenados por precio ascendente, primera página de 5. El cliente puede construir URLs así para construir UIs de catálogo completas.
Endpoints requeridos
| Método | Ruta | Descripción | Params |
|---|---|---|---|
| GET | / | Info del servicio | — |
| GET | /products | Lista con filtros y paginación | Query: category, min_price, max_price, search, sort_by, order, skip, limit |
| GET | /products/{product_id} | Obtiene producto por ID | Path: product_id |
| POST | /products | Crea producto | Body: dict |
| PUT | /products/{product_id} | Actualiza completo | Path + Body |
| PATCH | /products/{product_id} | Actualiza parcial | Path + Body |
| DELETE | /products/{product_id} | Elimina producto | Path |
Especificación de filtros (GET /products)
Query parameters:
| Param | Tipo | Default | Descripción |
|---|---|---|---|
category | str | None | None | Filtrar por categoría exacta |
min_price | float | None | None | Precio mínimo (>=) |
max_price | float | None | None | Precio máximo (<=) |
search | str | None | None | Búsqueda por nombre (case insensitive) |
sort_by | str | "id" | Campo: "id", "name", "price" |
order | str | "asc" | "asc" o "desc" |
skip | int | 0 | Offset para paginación |
limit | int | 10 | Máximo items por página |
Respuesta:
{
"total": 15,
"skip": 0,
"limit": 10,
"items": [...]
}
Código de referencia
app/main.py
from typing import Optional
from fastapi import FastAPI, Body
from fastapi.responses import JSONResponse
app = FastAPI(
title="Products API - Módulo 3",
description="API de productos con filtros, paginación y CRUD.",
version="1.0.0",
)
products = [
{"id": 1, "name": "Laptop", "category": "Electrónica", "price": 999.99, "in_stock": True},
{"id": 2, "name": "Mouse", "category": "Accesorios", "price": 29.99, "in_stock": True},
{"id": 3, "name": "Teclado", "category": "Accesorios", "price": 79.99, "in_stock": True},
{"id": 4, "name": "Monitor", "category": "Electrónica", "price": 299.99, "in_stock": True},
{"id": 5, "name": "Webcam", "category": "Accesorios", "price": 89.99, "in_stock": False},
{"id": 6, "name": "Auriculares", "category": "Accesorios", "price": 49.99, "in_stock": True},
]
def find_product(product_id: int):
return next((p for p in products if p["id"] == product_id), None)
def generate_id():
return max((p["id"] for p in products), default=0) + 1
@app.get("/")
def root():
return {"service": "Products API", "version": "1.0.0", "total_products": len(products)}
@app.get("/products")
def list_products(
category: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
search: Optional[str] = None,
sort_by: str = "id",
order: str = "asc",
skip: int = 0,
limit: int = 10,
):
result = list(products)
if category:
result = [p for p in result if p.get("category") == category]
if min_price is not None:
result = [p for p in result if p.get("price", 0) >= min_price]
if max_price is not None:
result = [p for p in result if p.get("price", float("inf")) <= max_price]
if search and search.strip():
search_lower = search.lower().strip()
result = [p for p in result if search_lower in p.get("name", "").lower()]
if sort_by in ("id", "name", "price"):
reverse = order.lower() == "desc"
result.sort(key=lambda p: p.get(sort_by, ""), reverse=reverse)
total = len(result)
paginated = result[skip : skip + limit]
return {"total": total, "skip": skip, "limit": limit, "items": paginated}
@app.get("/products/{product_id}")
def get_product(product_id: int):
product = find_product(product_id)
if product is None:
return JSONResponse(status_code=404, content={"error": "Product not found"})
return product
@app.post("/products", status_code=201)
def create_product(product: dict = Body(...)):
product["id"] = generate_id()
products.append(product)
return product
@app.put("/products/{product_id}")
def update_product(product_id: int, product: dict = Body(...)):
existing = find_product(product_id)
if existing is None:
return JSONResponse(status_code=404, content={"error": "Product not found"})
index = products.index(existing)
products[index] = {"id": product_id, **product}
return products[index]
@app.patch("/products/{product_id}")
def partial_update_product(product_id: int, updates: dict = Body(...)):
existing = find_product(product_id)
if existing is None:
return JSONResponse(status_code=404, content={"error": "Product not found"})
existing.update({k: v for k, v in updates.items() if k != "id"})
return existing
@app.delete("/products/{product_id}")
def delete_product(product_id: int):
product = find_product(product_id)
if product is None:
return JSONResponse(status_code=404, content={"error": "Product not found"})
products.remove(product)
return {"message": "Product deleted", "id": product_id}
Verificación paso a paso
- GET / — Info del servicio
- GET /products — Lista con defaults
- GET /products?category=Accesorios — Solo accesorios
- GET /products?min_price=50&max_price=100 — Rango de precios
- GET /products?search=laptop — Búsqueda por nombre (case insensitive)
- GET /products?sort_by=price&order=desc — Ordenado por precio descendente
- GET /products?skip=2&limit=3 — Paginación
- GET /products/1 — Producto por ID
- GET /products/99 — 404
- POST /products con body — Crear producto, status 201
- PUT /products/1 — Actualizar completo
- PATCH /products/2 con
{"in_stock": false}— Actualizar parcial - DELETE /products/3 — Eliminar
Sección de testing
Prueba cada endpoint y anota el resultado. Usa /docs o curl:
| Prueba | URL / Acción | Resultado esperado |
|---|---|---|
| Raíz | GET / | total_products: 6 |
| Lista completa | GET /products | 6 items, total 6 |
| Filtro categoría | GET /products?category=Electrónica | Laptop, Monitor |
| Rango precios | GET /products?min_price=50&max_price=100 | Teclado, Webcam, Auriculares |
| Búsqueda | GET /products?search=tecl | Teclado |
| Orden | GET /products?sort_by=price&order=desc | Laptop primero |
| Paginación | GET /products?skip=1&limit=2 | 2 items, total 6 |
| Detalle OK | GET /products/1 | Objeto Laptop |
| Detalle 404 | GET /products/99 | 404 |
| Crear | POST con body válido | 201, nuevo id |
| Actualizar | PUT /products/1 | 200 |
| Parcial | PATCH con {"in_stock": false} | 200 |
| Eliminar | DELETE /products/6 | 200, luego GET 404 |
Troubleshooting
1. Los filtros no aplican o devuelven resultados raros
- Causa: Usar
if categorycuandocategorypuede ser cadena vacía""(el cliente envía?category=). - Solución: Para strings usa
if category and category.strip(). Para números usaif min_price is not None— así0se trata como valor válido.
2. Error al ordenar con sort_by inexistente
- Causa: Si alguien envía
?sort_by=colory usasp.get(sort_by), no falla pero ordena porNone. - Solución: Valida con
if sort_by in ("id", "name", "price")antes de ordenar. Si no está en la lista, ignora el orden o usa "id" por defecto.
3. 404 sin JSONResponse
- Causa: Hacer
return {"error": "..."}con status 200 por defecto. - Solución: Usa
return JSONResponse(status_code=404, content={"error": "Product not found"})para que el cliente reciba el código correcto.
4. Conflicto de rutas (ruta estática vs dinámica)
- Causa: Si defines
/products/cheapestdespués de/products/{product_id}, FastAPI interpretará "cheapest" comoproduct_id. - Solución: Declara las rutas estáticas antes de la dinámica. El orden importa.
5. Body vacío o 422 en POST
- Causa: El cliente no envía
Content-Type: application/jsono el body está mal formado. - Solución: Verifica el header en la petición. En /docs, asegúrate de que el body sea JSON válido. Si usas curl, incluye
-H "Content-Type: application/json".
6. Paginación devuelve lista vacía con total > 0
- Causa:
skipmayor que el número de resultados filtrados (ej.skip=10con solo 6 productos). - Solución: Es correcto: devuelve
items: []contotal: 6. El cliente debe comprobartotalpara saber si hay más páginas.
Rúbrica de evaluación (100 puntos)
Endpoints CRUD (40 pts)
- (8) GET /products — Lista con filtros y paginación
- (8) GET /products/{id} — Detalle con 404
- (8) POST /products — Crear con status 201
- (5) PUT /products/{id} — Actualizar completo
- (5) PATCH /products/{id} — Actualizar parcial
- (6) DELETE /products/{id} — Eliminar con 404
Filtros y paginación (35 pts)
- (8) Filtro por categoría
- (8) Filtro por min_price y max_price
- (5) Búsqueda por nombre (search)
- (6) Ordenamiento sort_by y order
- (5) Paginación skip y limit
- (3) Respuesta con total, skip, limit, items
Código y datos (25 pts)
- (5) Al menos 6 productos precargados
- (5) find_product y generate_id reutilizados
- (5) 404 con JSONResponse en GET/PUT/PATCH/DELETE
- (5) Código limpio, sin duplicación
- (5) Metadata en FastAPI (title, version)
Checklist de completitud
- GET / retorna info del servicio
- GET /products aplica todos los filtros (category, min_price, max_price, search, sort_by, order)
- GET /products retorna total, skip, limit, items
- GET /products/{id} retorna producto o 404
- POST /products crea con status 201
- PUT /products/{id} actualiza completo y retorna 404 si no existe
- PATCH /products/{id} actualiza parcial
- DELETE /products/{id} elimina y retorna 404 si no existe
Reflexión: de CRUD básico a API con filtros
Al empezar el Módulo 3 tenías un CRUD que listaba todo. Ahora tu API soporta filtros por categoría, precio y nombre, ordenamiento y paginación — el mismo patrón que usan APIs de producción. La combinación path + query + body que practicaste aquí se repite en prácticamente cualquier recurso: usuarios, órdenes, artículos. Dominar esta estructura te permite diseñar endpoints predecibles y fáciles de consumir desde frontend o móvil.
Patrones aplicados
- find_product reutilizada: Evita duplicar la búsqueda en GET, PUT, PATCH y DELETE
- generate_id centralizado: Un solo punto para IDs auto-generados
- Filtros en secuencia: Aplicar category → min_price → max_price → search → sort → paginate
- JSONResponse para 404: Status code correcto con cuerpo JSON consistente
Flujo completo de verificación
Para validar la API de extremo a extremo:
1. POST /products → Crear "Tablet" (retorna id: 7)
2. GET /products/7 → Verificar que existe
3. GET /products?category=Electrónica → Debe incluir Tablet
4. PATCH /products/7 → Cambiar in_stock a false
5. PUT /products/7 → Actualizar todos los campos
6. DELETE /products/7 → Eliminar
7. GET /products/7 → Debe retornar 404
8. GET /products → Tablet no debe aparecer
Ideas para extender (opcional)
GET /products/categories— Lista de categorías únicasGET /products/cheapestyGET /products/expensive— Rutas estáticas- Query param
in_stock: Optional[bool]para filtrar por disponibilidad - Header
X-Total-Counten la respuesta deGET /products
Recuerda: rutas estáticas deben ir antes de /products/{product_id}.
Conexión con el siguiente módulo
En el Módulo 4 reemplazarás los dict por modelos Pydantic para validación robusta (campos requeridos, rangos, formatos). En el Módulo 5 sustituirás los JSONResponse manuales por HTTPException para un manejo de errores más estándar.
Resumen
- API de productos con CRUD, filtros por categoría, precio y nombre, ordenamiento y paginación
- Path params para ID, query params para filtros, body para POST/PUT/PATCH
- Status 201 en POST, 404 con JSONResponse cuando el recurso no existe
- Búsqueda por nombre case insensitive con el param
search - En el Módulo 4 agregarás Pydantic para validación; en el Módulo 5, HTTPException para errores estándar
Recursos Adicionales
- FastAPI - Path Parameters — Validación con type hints
- FastAPI - Query Parameters — Filtros y defaults
- FastAPI - Path, Query, Body — Combinación
- REST API - Filtering — Convenciones de filtrado
- REST API - Pagination — Patrones skip/limit y offset/limit
- FastAPI - Request Body — Body con Body()
Comandos curl de referencia
# GET con filtros
curl "http://127.0.0.1:8000/products?category=Electrónica&min_price=100"
curl "http://127.0.0.1:8000/products?sort_by=price&order=desc&limit=3"
curl "http://127.0.0.1:8000/products?search=laptop"
# POST
curl -X POST http://127.0.0.1:8000/products \
-H "Content-Type: application/json" \
-d '{"name": "Tablet", "category": "Electrónica", "price": 299.99, "in_stock": true}'
# PUT
curl -X PUT http://127.0.0.1:8000/products/1 \
-H "Content-Type: application/json" \
-d '{"name": "Laptop Pro", "category": "Electrónica", "price": 1299.99, "in_stock": true}'
# PATCH
curl -X PATCH http://127.0.0.1:8000/products/2 \
-H "Content-Type: application/json" \
-d '{"in_stock": false}'
# DELETE
curl -X DELETE http://127.0.0.1:8000/products/6
Módulo 3, Cápsula 06 — FastAPI Fundamentals Guide