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, skip y limit para 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, JSONResponse para 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:

CampoTipoDescripción
idintIdentificador único
namestrNombre del producto
categorystrCategoría (Electrónica, Hogar, Deportes, etc.)
pricefloatPrecio
in_stockboolDisponibilidad

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:

  1. Filtro por categoría — Restringe al conjunto de categoría
  2. Filtro por min_price / max_price — Acota por rango de precios
  3. Búsqueda por nombre — Filtra por texto en el nombre
  4. Ordenamiento — Ordena el resultado filtrado
  5. 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étodoRutaDescripciónParams
GET/Info del servicio
GET/productsLista con filtros y paginaciónQuery: category, min_price, max_price, search, sort_by, order, skip, limit
GET/products/{product_id}Obtiene producto por IDPath: product_id
POST/productsCrea productoBody: dict
PUT/products/{product_id}Actualiza completoPath + Body
PATCH/products/{product_id}Actualiza parcialPath + Body
DELETE/products/{product_id}Elimina productoPath

Especificación de filtros (GET /products)

Query parameters:

ParamTipoDefaultDescripción
categorystr | NoneNoneFiltrar por categoría exacta
min_pricefloat | NoneNonePrecio mínimo (>=)
max_pricefloat | NoneNonePrecio máximo (<=)
searchstr | NoneNoneBúsqueda por nombre (case insensitive)
sort_bystr"id"Campo: "id", "name", "price"
orderstr"asc""asc" o "desc"
skipint0Offset para paginación
limitint10Má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

  1. GET / — Info del servicio
  2. GET /products — Lista con defaults
  3. GET /products?category=Accesorios — Solo accesorios
  4. GET /products?min_price=50&max_price=100 — Rango de precios
  5. GET /products?search=laptop — Búsqueda por nombre (case insensitive)
  6. GET /products?sort_by=price&order=desc — Ordenado por precio descendente
  7. GET /products?skip=2&limit=3 — Paginación
  8. GET /products/1 — Producto por ID
  9. GET /products/99 — 404
  10. POST /products con body — Crear producto, status 201
  11. PUT /products/1 — Actualizar completo
  12. PATCH /products/2 con {"in_stock": false} — Actualizar parcial
  13. DELETE /products/3 — Eliminar

Sección de testing

Prueba cada endpoint y anota el resultado. Usa /docs o curl:

PruebaURL / AcciónResultado esperado
RaízGET /total_products: 6
Lista completaGET /products6 items, total 6
Filtro categoríaGET /products?category=ElectrónicaLaptop, Monitor
Rango preciosGET /products?min_price=50&max_price=100Teclado, Webcam, Auriculares
BúsquedaGET /products?search=teclTeclado
OrdenGET /products?sort_by=price&order=descLaptop primero
PaginaciónGET /products?skip=1&limit=22 items, total 6
Detalle OKGET /products/1Objeto Laptop
Detalle 404GET /products/99404
CrearPOST con body válido201, nuevo id
ActualizarPUT /products/1200
ParcialPATCH con {"in_stock": false}200
EliminarDELETE /products/6200, luego GET 404

Troubleshooting

1. Los filtros no aplican o devuelven resultados raros

  • Causa: Usar if category cuando category puede ser cadena vacía "" (el cliente envía ?category=).
  • Solución: Para strings usa if category and category.strip(). Para números usa if min_price is not None — así 0 se trata como valor válido.

2. Error al ordenar con sort_by inexistente

  • Causa: Si alguien envía ?sort_by=color y usas p.get(sort_by), no falla pero ordena por None.
  • 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/cheapest después de /products/{product_id}, FastAPI interpretará "cheapest" como product_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/json o 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: skip mayor que el número de resultados filtrados (ej. skip=10 con solo 6 productos).
  • Solución: Es correcto: devuelve items: [] con total: 6. El cliente debe comprobar total para 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 únicas
  • GET /products/cheapest y GET /products/expensive — Rutas estáticas
  • Query param in_stock: Optional[bool] para filtrar por disponibilidad
  • Header X-Total-Count en la respuesta de GET /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

  1. FastAPI - Path Parameters — Validación con type hints
  2. FastAPI - Query Parameters — Filtros y defaults
  3. FastAPI - Path, Query, Body — Combinación
  4. REST API - Filtering — Convenciones de filtrado
  5. REST API - Pagination — Patrones skip/limit y offset/limit
  6. 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