Module 3: Request and Response

Path(), Query(), Body() — Control Avanzado de Parámetros

Descripción de la cápsula

En las cápsulas anteriores aprendiste a recibir path parameters con type hints, query parameters con valores por defecto, y request body con Body(...). Todo eso funciona — pero es como usar un destornillador básico cuando existe uno eléctrico. FastAPI incluye las funciones Path(), Query() y Body() que te dan control fino sobre cada parámetro: descripciones que aparecen en /docs, alias para query strings, constraints de validación (valores mínimos, máximos, longitudes), marcado de deprecación, y ejemplos embebidos en la documentación.

¿Por qué importa? Porque una API profesional no solo funciona — se documenta sola. Cuando usas Query(description="Filter by genre"), cualquier developer que abra /docs entiende qué hace ese parámetro sin leer tu código. Cuando usas Path(ge=1), FastAPI rechaza automáticamente IDs negativos con un error 422 claro, sin que escribas un solo if. Son herramientas que convierten tus endpoints de "funciona" a "funciona y se explica solo."

Al terminar esta cápsula sabrás cuándo usar type hints simples y cuándo necesitas Path(), Query() o Body(), cómo configurar validación numérica y de strings, cómo combinar los tres tipos de parámetros en un solo endpoint, y cómo luce todo esto en la documentación automática.


Por qué existen Path(), Query() y Body()

Type hints simples: Lo que ya sabes

Hasta ahora has escrito endpoints así:

from fastapi import FastAPI

app = FastAPI()

books = [
    {"id": 1, "title": "Cien años de soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico"},
    {"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novela"},
    {"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental"},
    {"id": 4, "title": "La casa de los espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico"},
    {"id": 5, "title": "Ficciones", "author": "Jorge Luis Borges", "year": 1944, "genre": "Cuentos"},
]


@app.get("/books/{book_id}")
def get_book(book_id: int):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return {"error": "Book not found"}
    return book

book_id: int funciona. FastAPI sabe que viene de la URL, valida que sea entero, y listo. Pero no tienes forma de decirle "el ID debe ser positivo", ni de agregar una descripción que aparezca en /docs, ni de definir un ejemplo. El type hint solo le dice qué tipo es — no le dice qué reglas debe cumplir.

Las funciones de control: Lo que vas a aprender

Path(), Query() y Body() son funciones que te permiten agregar metadatos y restricciones a cada parámetro. Piensa en ellas como "type hints con superpoderes":

from fastapi import FastAPI, Path, Query, Body

Cada una se usa como valor por defecto del parámetro:

def get_book(book_id: int = Path(..., ge=1, description="ID del libro")):

En lugar de solo book_id: int, ahora tienes: es requerido (...), debe ser mayor o igual a 1 (ge=1), y aparece con descripción en /docs.


Query() — Control de query parameters

Ejemplo básico: Descripciones para documentación

Toma el endpoint de filtrar libros que ya conoces y agrégale Query():

from fastapi import FastAPI, Query

app = FastAPI()

books = [
    {"id": 1, "title": "Cien años de soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico"},
    {"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novela"},
    {"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental"},
    {"id": 4, "title": "La casa de los espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico"},
    {"id": 5, "title": "Ficciones", "author": "Jorge Luis Borges", "year": 1944, "genre": "Cuentos"},
]


@app.get("/books")
def list_books(
    genre: str = Query(default=None, description="Filtrar por género literario"),
    limit: int = Query(default=10, description="Máximo de resultados a retornar"),
):
    results = books

    if genre:
        results = [b for b in results if b["genre"].lower() == genre.lower()]

    return results[:limit]

Sin Query(), esos parámetros aparecen en /docs como simples campos sin contexto. Con Query(description=...), cada parámetro tiene una explicación clara. Abre http://127.0.0.1:8000/docs, expande GET /books, y verás las descripciones junto a cada campo.

curl "http://127.0.0.1:8000/books?genre=Novela&limit=2"

Output esperado:

[
  {"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novela"}
]

Ejemplo intermedio: Constraints de validación

Aquí es donde Query() brilla. Puedes restringir valores numéricos y longitud de strings. Usando el mismo setup de app y books:

@app.get("/books")
def list_books(
    genre: str = Query(default=None, description="Filtrar por género literario"),
    limit: int = Query(default=10, ge=1, le=100, description="Máximo de resultados (1-100)"),
    search: str = Query(default=None, min_length=2, max_length=50, description="Buscar en títulos"),
):
    results = books

    if genre:
        results = [b for b in results if b["genre"].lower() == genre.lower()]

    if search:
        results = [b for b in results if search.lower() in b["title"].lower()]

    return results[:limit]

Los constraints:

ConstraintAplica aSignificado
ge=1NúmerosGreater than or Equal — mínimo 1
le=100NúmerosLess than or Equal — máximo 100
gt=0NúmerosGreater Than — estrictamente mayor que 0
lt=1000NúmerosLess Than — estrictamente menor que 1000
min_length=2StringsLongitud mínima de 2 caracteres
max_length=50StringsLongitud máxima de 50 caracteres

Prueba violar un constraint:

curl "http://127.0.0.1:8000/books?limit=0"

Output — FastAPI retorna 422 automáticamente:

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["query", "limit"],
      "msg": "Input should be greater than or equal to 1",
      "input": "0",
      "ctx": {"ge": 1}
    }
  ]
}

No escribiste ningún if limit < 1. FastAPI validó por ti gracias a ge=1. El error es claro: dice qué parámetro falló (limit), de dónde viene (query), y cuál es la regla (ge: 1).

curl "http://127.0.0.1:8000/books?search=a"

Output — falla por min_length=2:

{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["query", "search"],
      "msg": "String should have at least 2 characters",
      "input": "a",
      "ctx": {"min_length": 2}
    }
  ]
}

Ejemplo avanzado: Alias y deprecación

Query() tiene dos opciones más que verás en APIs reales:

@app.get("/books")
def list_books(
    genre: str = Query(default=None, description="Filtrar por género"),
    limit: int = Query(default=10, ge=1, le=100, description="Máximo de resultados"),
    search: str = Query(default=None, min_length=2, max_length=50, description="Buscar en títulos"),
    sort_by: str = Query(default=None, alias="sort-by", description="Campo para ordenar"),
    old_filter: str = Query(default=None, deprecated=True, description="Usar 'genre' en su lugar"),
):
    results = books

    if genre:
        results = [b for b in results if b["genre"].lower() == genre.lower()]

    if search:
        results = [b for b in results if search.lower() in b["title"].lower()]

    return results[:limit]
  • alias="sort-by" — El cliente envía ?sort-by=title, pero en tu código usas sort_by (Python no permite guiones en nombres de variables). El alias conecta el nombre de la URL con el nombre de Python.
  • deprecated=True — En /docs, este parámetro aparece tachado y marcado como deprecated. Los clientes ven que deben dejar de usarlo.
curl "http://127.0.0.1:8000/books?sort-by=title"

Path() — Control de path parameters

Ejemplo básico: Descripción y validación

Agrega Path al import (from fastapi import FastAPI, Path) y modifica el endpoint de obtener libro por ID:

@app.get("/books/{book_id}")
def get_book(
    book_id: int = Path(..., ge=1, description="ID del libro, debe ser positivo"),
):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return {"error": "Book not found"}
    return book

Los tres puntos (...) significan que el parámetro es requerido — que en el caso de path parameters siempre lo es (viene de la URL). ge=1 garantiza que nadie pase un ID de 0 o negativo.

curl http://127.0.0.1:8000/books/3

Output esperado:

{"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental"}
curl http://127.0.0.1:8000/books/0

Output — rechazado por ge=1:

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["path", "book_id"],
      "msg": "Input should be greater than or equal to 1",
      "input": "0",
      "ctx": {"ge": 1}
    }
  ]
}

Sin Path(ge=1), el request pasaría y tu función buscaría un libro con id=0 — no lo encontraría, pero el error sería menos claro. Con Path(), FastAPI intercepta el valor inválido antes de que tu código se ejecute.

Prueba también con /books/-5 — mismo rechazo. Cualquier valor menor a 1 es interceptado antes de que tu código se ejecute.

Ejemplo intermedio: Path con título para docs

@app.get("/books/{book_id}")
def get_book(
    book_id: int = Path(
        ...,
        ge=1,
        le=10000,
        title="Book ID",
        description="Identificador único del libro, positivo y menor a 10000",
    ),
):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return {"error": "Book not found"}
    return book

title y description aparecen en la documentación OpenAPI. title es el nombre corto, description es la explicación detallada. Esto hace que tu API sea autodocumentada — otro developer lee /docs y entiende qué espera cada parámetro sin preguntarte.


Body() — Control del request body (con metadatos)

Ya usaste Body(...) en el Módulo 2 para indicar que un dict viene del body. Ahora vas a agregar metadatos que mejoran la documentación.

Ejemplo básico: Body con ejemplo y descripción

Agrega Body al import y usa la misma lista books con generate_id() del Módulo 2:

@app.post("/books", status_code=201)
def create_book(
    book: dict = Body(
        ...,
        description="Datos del libro a crear. Debe incluir title y author como mínimo.",
        example={
            "title": "El Aleph",
            "author": "Jorge Luis Borges",
            "year": 1949,
            "genre": "Cuentos",
        },
    ),
):
    book["id"] = generate_id()
    books.append(book)
    return book

Dos parámetros clave:

  • example hace que /docs muestre ese JSON pre-llenado en el textarea cuando haces "Try it out". En lugar de un body vacío, el developer ve datos realistas que puede ejecutar directamente.
  • description aparece junto al campo del body en /docs. El developer sabe qué campos son esperados antes de probar.
curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Pedro Páramo", "author": "Juan Rulfo", "year": 1955, "genre": "Novela"}'

Output esperado:

{"title": "Pedro Páramo", "author": "Juan Rulfo", "year": 1955, "genre": "Novela", "id": 4}

Combinando Path + Query + Body en un endpoint

Aquí es donde todo se conecta. Un endpoint PUT que actualiza un libro necesita tres fuentes de datos al mismo tiempo. Usando el mismo setup con Path, Query y Body importados:

@app.put("/books/{book_id}")
def update_book(
    book_id: int = Path(..., ge=1, description="ID del libro a actualizar"),
    notify: bool = Query(default=False, description="Enviar notificación del cambio"),
    book_data: dict = Body(
        ...,
        example={"title": "Título actualizado", "author": "Autor actualizado"},
    ),
):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return {"error": "Book not found"}

    book.update(book_data)

    result = {"updated_book": book}
    if notify:
        result["notification"] = f"Book {book_id} was updated"

    return result

¿Cómo distingue FastAPI de dónde viene cada dato?

La regla es clara y determinista:

FuenteCómo lo identifica FastAPIEjemplo
Path parameterEl nombre aparece en la ruta ({book_id})/books/3book_id = 3
Query parameterTipo simple (str, int, bool) que NO está en la ruta?notify=truenotify = True
BodyMarcado con Body(), o tipo complejo como un modelo PydanticJSON en el cuerpo del request

No hay magia. Si el nombre del parámetro coincide con un {placeholder} en la ruta, es path. Si es un tipo simple sin {placeholder}, es query. Si tiene Body(), es body.

Prueba el endpoint combinado:

curl -X PUT "http://127.0.0.1:8000/books/1?notify=true" \
  -H "Content-Type: application/json" \
  -d '{"title": "Cien años de soledad (Edición especial)"}'

Output esperado:

{
  "updated_book": {
    "id": 1,
    "title": "Cien años de soledad (Edición especial)",
    "author": "Gabriel García Márquez",
    "year": 1967,
    "genre": "Realismo mágico"
  },
  "notification": "Book 1 was updated"
}

Sin ?notify=true:

curl -X PUT "http://127.0.0.1:8000/books/2" \
  -H "Content-Type: application/json" \
  -d '{"year": 1615}'

Output esperado — sin notificación:

{
  "updated_book": {
    "id": 2,
    "title": "Don Quijote de la Mancha",
    "author": "Miguel de Cervantes",
    "year": 1615,
    "genre": "Novela"
  }
}

Validación de strings con pattern

Además de min_length y max_length, puedes validar strings con expresiones regulares:

@app.get("/books")
def list_books(
    genre: str = Query(
        default=None,
        min_length=2,
        max_length=30,
        pattern="^[a-zA-ZáéíóúÁÉÍÓÚñÑ ]+$",
        description="Solo letras y espacios",
    ),
):
    return {"genre_filter": genre}

pattern acepta una expresión regular. En este caso, solo permite letras (incluyendo acentos) y espacios. Si el cliente envía genre=Sci-Fi!, FastAPI retorna 422 porque contiene caracteres no permitidos.

curl "http://127.0.0.1:8000/books?genre=Realismo%20mágico"

Output:

{"genre_filter": "Realismo mágico"}
curl "http://127.0.0.1:8000/books?genre=Sci-Fi!"

Output — rechazado por el pattern:

{
  "detail": [
    {
      "type": "string_pattern_mismatch",
      "loc": ["query", "genre"],
      "msg": "String should match pattern '^[a-zA-ZáéíóúÁÉÍÓÚñÑ ]+$'"
    }
  ]
}

Comparación: Type hints simples vs Path()/Query()/Body()

AspectoType hint simplePath()/Query()/Body()
Validación de tipoSí (int, str, bool)
Valor por defectoparam: int = 10Query(default=10)
Descripción en /docsNodescription="..."
Constraints numéricosNoge, le, gt, lt
Constraints de stringNomin_length, max_length, pattern
Ejemplo en /docsNoexample={...}
AliasNoalias="sort-by"
Deprecar parámetroNodeprecated=True
Título para docsNotitle="..."
Cuándo usarloPrototipos, endpoints simplesAPIs profesionales, endpoints públicos

La recomendación: empieza con type hints simples cuando estás prototipando. Cuando el endpoint va a producción o lo consume otro equipo, agrega Path(), Query() y Body() con descripciones y constraints. No hay overhead de rendimiento — solo metadatos.


Conexión con Proyecto

Los constraints de Query() y Path() que aprendiste aquí aparecen directamente en el proyecto CRUD del Módulo 6. Cuando implementes GET /tasks?status=pending&limit=20, usarás Query(description=..., ge=..., le=...) para que la API se documente sola y rechace valores inválidos. Path(ge=1) protegerá todos los endpoints que reciben un task_id. En el Módulo 4 aprenderás Pydantic models, que hacen innecesario Body() para dicts — pero Path() y Query() los seguirás usando siempre.


Troubleshooting

Problema 1: El constraint no parece aplicarse

Causa: Usaste un type hint simple en lugar de Query() o Path().

Solución:

# ❌ ge no existe en type hints simples — esto da error de sintaxis
@app.get("/books")
def list_books(limit: int = 10, ge=1):
    ...

# ✅ ge va dentro de Query()
@app.get("/books")
def list_books(limit: int = Query(default=10, ge=1)):
    ...

ge, le, min_length, max_length y pattern solo funcionan dentro de Path(), Query() o Body(). No son parámetros de la función del endpoint.

Problema 2: TypeError al usar Query() sin default en parámetro opcional

Causa: Marcaste el parámetro como requerido (...) pero esperabas que fuera opcional.

Solución:

# ❌ Requerido — si no lo envías, error 422
@app.get("/books")
def list_books(genre: str = Query(..., description="Género")):
    ...

# ✅ Opcional — default=None
@app.get("/books")
def list_books(genre: str = Query(default=None, description="Género")):
    ...

Query(...) (con ...) significa "este query parameter es obligatorio." Query(default=None) lo hace opcional. Para query parameters, opcional es casi siempre lo correcto.

Problema 3: El alias no funciona — el parámetro siempre es None

Causa: Estás enviando el nombre de Python en la URL en lugar del alias.

Solución:

@app.get("/books")
def list_books(
    sort_by: str = Query(default=None, alias="sort-by"),
):
    return {"sort": sort_by}
# ❌ Usa el nombre Python — no funciona
curl "http://127.0.0.1:8000/books?sort_by=title"
# sort_by será None porque FastAPI busca "sort-by"

# ✅ Usa el alias definido
curl "http://127.0.0.1:8000/books?sort-by=title"
# sort_by será "title"

Cuando defines un alias, el cliente debe usar el alias en la URL. El nombre de Python (sort_by) es solo para tu código interno.

Problema 4: pattern rechaza caracteres válidos como acentos

Causa: Tu regex no incluye caracteres con acento ni la ñ.

Solución:

# ❌ Solo ASCII — rechaza "Realismo mágico"
genre: str = Query(default=None, pattern="^[a-zA-Z ]+$")

# ✅ Incluye acentos y ñ
genre: str = Query(default=None, pattern="^[a-zA-ZáéíóúÁÉÍÓÚñÑ ]+$")

Si tu API maneja datos en español, recuerda incluir los caracteres acentuados en los patterns.


Ejercicios

Ejercicio 1: Query parameters con constraints (Fácil)

Crea un endpoint GET /books que reciba skip (default 0, mínimo 0) y limit (default 5, mínimo 1, máximo 20). Ambos con descripciones. Retorna los libros desde skip hasta skip + limit.

Ver solución

Usando la misma lista books de 5 libros de la cápsula:

@app.get("/books")
def list_books(
    skip: int = Query(default=0, ge=0, description="Número de libros a saltar"),
    limit: int = Query(default=5, ge=1, le=20, description="Máximo de resultados (1-20)"),
):
    return books[skip : skip + limit]
curl "http://127.0.0.1:8000/books?skip=2&limit=2"
# → [Rayuela, La casa de los espíritus]

curl "http://127.0.0.1:8000/books?limit=0"
# → Error 422: "Input should be greater than or equal to 1"

curl "http://127.0.0.1:8000/books?skip=-1"
# → Error 422: "Input should be greater than or equal to 0"

Ejercicio 2: Path con validación estricta (Fácil)

Crea un endpoint GET /books/{book_id} donde book_id debe ser un entero entre 1 y 999 (inclusive). Agrega descripción y título. Prueba con valores fuera de rango.

Ver solución

Usando la misma lista books de la cápsula:

@app.get("/books/{book_id}")
def get_book(
    book_id: int = Path(
        ...,
        ge=1,
        le=999,
        title="Book ID",
        description="Identificador del libro (1-999)",
    ),
):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return {"error": "Book not found"}
    return book
curl http://127.0.0.1:8000/books/1
# → {"id": 1, "title": "Cien años de soledad", ...}

curl http://127.0.0.1:8000/books/0
# → Error 422: ge=1

curl http://127.0.0.1:8000/books/1000
# → Error 422: le=999

Ejercicio 3: Búsqueda con min_length y max_length (Medio)

Crea un endpoint GET /books/search que reciba un query parameter q (requerido, mínimo 3 caracteres, máximo 100) y retorne los libros cuyo título o autor contengan ese texto. Agrega descripción.

Ver solución

Usando la misma lista books de 5 libros:

@app.get("/books/search")
def search_books(
    q: str = Query(
        ...,
        min_length=3,
        max_length=100,
        description="Texto de búsqueda (3-100 caracteres). Busca en título y autor.",
    ),
):
    query = q.lower()
    results = [
        b for b in books
        if query in b["title"].lower() or query in b["author"].lower()
    ]
    return {"query": q, "total_results": len(results), "books": results}
curl "http://127.0.0.1:8000/books/search?q=borges"
# → {"query": "borges", "total_results": 1, "books": [{...Ficciones...}]}

curl "http://127.0.0.1:8000/books/search?q=ab"
# → Error 422: min_length=3

curl "http://127.0.0.1:8000/books/search?q=soledad"
# → {"query": "soledad", "total_results": 1, "books": [{...Cien años...}]}

Ejercicio 4: PUT con Path + Query + Body completo (Medio)

Crea un endpoint PUT /books/{book_id} que:

  • book_id: Path, requerido, ge=1
  • replace: Query, default False, descripción "Si true, reemplaza todos los campos"
  • book_data: Body, con ejemplo embebido

Si replace es True, reemplaza el libro completo (conservando el id). Si es False, solo actualiza los campos enviados.

Ver solución

Usando la misma lista books de la cápsula:

@app.put("/books/{book_id}")
def update_book(
    book_id: int = Path(..., ge=1, description="ID del libro a actualizar"),
    replace: bool = Query(default=False, description="Si true, reemplaza todos los campos"),
    book_data: dict = Body(
        ...,
        example={"title": "Nuevo título", "author": "Nuevo autor", "year": 2024, "genre": "Ficción"},
    ),
):
    book_index = next((i for i, b in enumerate(books) if b["id"] == book_id), None)
    if book_index is None:
        return {"error": "Book not found"}

    if replace:
        book_data["id"] = book_id
        books[book_index] = book_data
    else:
        books[book_index].update(book_data)

    return {"mode": "replace" if replace else "merge", "book": books[book_index]}
# Merge (default) — solo actualiza year
curl -X PUT "http://127.0.0.1:8000/books/1" \
  -H "Content-Type: application/json" \
  -d '{"year": 2024}'
# → {"mode": "merge", "book": {"id": 1, "title": "Cien años de soledad", ..., "year": 2024}}

# Replace — reemplaza todo excepto id
curl -X PUT "http://127.0.0.1:8000/books/2?replace=true" \
  -H "Content-Type: application/json" \
  -d '{"title": "Nuevo libro", "author": "Nuevo autor"}'
# → {"mode": "replace", "book": {"title": "Nuevo libro", "author": "Nuevo autor", "id": 2}}

# ID inválido
curl -X PUT "http://127.0.0.1:8000/books/0" \
  -H "Content-Type: application/json" \
  -d '{"title": "Test"}'
# → Error 422: ge=1

Resumen

  • Query() agrega descripciones, constraints y alias a query parameters — hace que /docs sea autodocumentado
  • Path() agrega validación a path parameters — ge=1 evita IDs negativos o cero antes de que tu código se ejecute
  • Body() agrega ejemplos y descripciones al request body — mejora la experiencia en /docs
  • Constraints numéricos: ge (>=), gt (>), le (<=), lt (<) — FastAPI retorna 422 automáticamente si se violan
  • Constraints de string: min_length, max_length, pattern — validación sin escribir if manuales
  • FastAPI distingue parámetros por ubicación: path → de la URL, query → de ?key=value, body → del JSON
  • alias conecta nombres de URL (con guiones) con nombres Python (con guiones bajos)
  • deprecated=True marca parámetros como obsoletos en la documentación
  • Empieza simple, agrega control después: type hints para prototipos, Path()/Query()/Body() para producción

Próxima cápsula: Headers y Cookies — Aprenderás a leer headers HTTP y cookies con Header() y Cookie(), completando las cuatro fuentes de datos que FastAPI maneja en un request.


Recursos Adicionales

  1. FastAPI - Query Parameters and String Validations - Query() con constraints y metadatos en la documentación oficial
  2. FastAPI - Path Parameters and Numeric Validations - Path() con validaciones numéricas
  3. FastAPI - Body - Fields - Body() y Field() para metadatos del request body
  4. FastAPI - Body - Multiple Parameters - Combinar Path, Query y Body en un endpoint
  5. OpenAPI Specification - Parameter Object - Cómo los metadatos de Path()/Query() se traducen a OpenAPI
  6. FastAPI - Schema Extra/Example - Ejemplos embebidos en la documentación automática