Module 3: Request and Response

Request Body: JSON, Dict y Combinación de Parámetros

Descripción de la cápsula

En el Módulo 2 usaste Body() para recibir un diccionario en POST. El request body es donde el cliente envía datos complejos — objetos JSON con múltiples campos — en operaciones de creación y actualización. A diferencia del path y la query string, el body no vive en la URL: viaja en el cuerpo del mensaje HTTP, lo que permite enviar estructuras arbitrariamente grandes sin limitaciones de longitud ni caracteres especiales.

En esta cápsula profundizas en cómo FastAPI interpreta el body, las diferencias entre usar dict y un modelo Pydantic (preview), y cómo combinar path parameters, query parameters y body en un solo endpoint. También verás patrones como PUT vs PATCH, body opcional, y body con listas (creación bulk), así como los errores más frecuentes al trabajar con el body.

Al terminar dominarás endpoints como POST /items (solo body), PUT /items/{item_id} (path + body), GET /items?category=X (solo query) y combinaciones como POST /items?notify=true — el repertorio completo de fuentes de datos en una API REST.


Modelo mental: dónde va el body en la petición HTTP

Una petición HTTP tiene una estructura clara. Imagina el mensaje como un sobre:

POST /items?notify=true HTTP/1.1
Host: localhost:8000
Content-Type: application/json
Content-Length: 42

{"name": "Laptop", "price": 999.99}
  • Línea de inicio: método + path + query (POST /items?notify=true)
  • Headers: metadatos como Content-Type, Content-Length
  • Línea en blanco: separa headers del cuerpo
  • Body: el JSON que envía el cliente

El body es todo lo que va después de la línea en blanco. No forma parte de la URL, por eso no tiene límite de longitud ni problemas con caracteres especiales. FastAPI lee este bloque cuando declaras un parámetro con Body() o un modelo Pydantic.


Request body: el concepto

Dónde viajan los datos

En una petición HTTP, los datos pueden ir en:

UbicaciónEjemploUso típico
Path/items/5ID del recurso
Query?skip=0&limit=10Filtros, paginación
Body{"name": "X", "price": 99}Crear/actualizar recursos

El body es la parte del request que contiene el payload — normalmente JSON. Va en el cuerpo de la petición, no en la URL.

Content-Type

Para enviar JSON, el cliente debe usar el header:

Content-Type: application/json

FastAPI lo espera por defecto. Si envías otro Content-Type, la interpretación puede cambiar.


Recibir JSON con dict y Body()

Sintaxis básica

from fastapi import FastAPI, Body

app = FastAPI()

items = []


@app.post("/items")
def create_item(item: dict = Body(...)):
    item["id"] = len(items) + 1
    items.append(item)
    return item
  • Body(...) indica que el parámetro viene del body y es obligatorio
  • ... (Ellipsis) significa "requerido"
  • Sin Body(), FastAPI podría interpretar dict de forma ambigua

Probar con curl

curl -X POST http://127.0.0.1:8000/items \
  -H "Content-Type: application/json" \
  -d '{"name": "Laptop", "price": 999.99, "category": "Electrónica"}'

Probar en /docs

En la documentación interactiva, el body aparece como un editor JSON. Puedes escribir el objeto y ejecutar. Si tienes múltiples parámetros (por ejemplo body + query), Swagger muestra cada uno en su sección correspondiente; el body se edita en el área "Request body" del ejemplo.

Tips para debugging

  • Ver el body recibido: añade temporalmente print(item) al inicio de la función para inspeccionar qué llega.
  • Probar con httpx: en tests, usa client.post("/items", json={"name": "X", "price": 99}); httpx envía Content-Type: application/json automáticamente.
  • Logs de uvicorn: si el body no llega, revisa que no haya middleware que consuma el stream antes de que FastAPI lo lea.

Variantes: Body con valores por defecto

Puedes usar Body(default=...) para valores por defecto cuando el body sea opcional:

@app.patch("/items/{item_id}")
def partial_update(item_id: int, updates: dict = Body(default_factory=dict)):
    if not updates:
        return {"message": "No updates provided"}
    # ...

default_factory=dict crea un dict vacío si no se envía body. Alternativamente, Body(None) con Optional[dict] te da None explícitamente cuando no hay body.


¿Por qué Body()?

FastAPI necesita saber de dónde leer cada parámetro:

  • Parámetro en la ruta {param} → path
  • Parámetro no en la ruta, tipo simple (str, int, etc.) → query por defecto
  • Parámetro complejo (dict, list, modelo Pydantic) → body por defecto solo con Pydantic

Con un dict sin indicación, el comportamiento no siempre es claro. Con Body() le dices explícitamente: "lee esto del body."

Con un modelo Pydantic (Módulo 4), FastAPI asume automáticamente que es body. Por ahora, Body() con dict es la forma segura.


Request body opcional

A veces el body puede ser opcional (por ejemplo, en PATCH para actualización parcial):

from typing import Optional

@app.patch("/items/{item_id}")
def partial_update(item_id: int, updates: Optional[dict] = Body(None)):
    if updates is None:
        return {"message": "No updates provided"}
    # Aplicar updates al item...
    return {"item_id": item_id, "updated": updates}

Body(None) — si no se envía body, updates será None.


Pydantic BaseModel: preview

En el Módulo 4 usarás Pydantic para validación robusta. Aquí un adelanto:

from pydantic import BaseModel

class ItemCreate(BaseModel):
    name: str
    price: float
    category: str | None = None


@app.post("/items")
def create_item(item: ItemCreate):  # Sin Body() — FastAPI sabe que es body
    return {"name": item.name, "price": item.price, "category": item.category}

Con Pydantic:

  • No necesitas Body() — FastAPI asume body automáticamente
  • Validación automática (tipos, campos requeridos)
  • Documentación generada con el esquema del modelo

Por ahora sigue con dict = Body(...); en el Módulo 4 migrarás a modelos Pydantic.


Combinar path + body

El patrón más común: path para identificar el recurso, body para los datos.

PUT: actualizar recurso completo

@app.put("/items/{item_id}")
def update_item(item_id: int, item: dict = Body(...)):
    existing = next((i for i in items if i["id"] == item_id), None)
    if existing is None:
        return {"error": "Item not found"}
    index = items.index(existing)
    items[index] = {"id": item_id, **item}
    return items[index]
  • item_id viene del path
  • item viene del body
  • FastAPI inyecta ambos correctamente

PATCH: actualización parcial

@app.patch("/items/{item_id}")
def partial_update_item(item_id: int, updates: dict = Body(...)):
    existing = next((i for i in items if i["id"] == item_id), None)
    if existing is None:
        return {"error": "Item not found"}
    existing.update({k: v for k, v in updates.items() if k != "id"})
    return existing

Combinar path + query + body

Los tres pueden coexistir en el mismo endpoint.

Ejemplo: crear item con metadata en query

@app.post("/items")
def create_item(
    item: dict = Body(...),
    notify: bool = False,  # query param
):
    item["id"] = len(items) + 1
    items.append(item)
    if notify:
        # Simular notificación
        item["_notified"] = True
    return item
curl -X POST "http://127.0.0.1:8000/items?notify=true" \
  -H "Content-Type: application/json" \
  -d '{"name": "Laptop", "price": 999}'

Ejemplo completo: path + query + body en un solo endpoint

El siguiente endpoint muestra los tres tipos de parámetros en acción. Sirve para crear un item dentro de una categoría existente, con opciones de notificación y modo de validación:

@app.post("/categories/{category_id}/items")
def create_item_in_category(
    category_id: int,                    # PATH: identifica la categoría
    item: dict = Body(...),              # BODY: datos del item
    notify: bool = False,                 # QUERY: enviar notificación
    draft: bool = False,                 # QUERY: guardar como borrador
):
    # Buscar categoría
    category = next((c for c in categories if c["id"] == category_id), None)
    if category is None:
        return {"error": "Category not found"}
    # Crear item
    item["id"] = generate_id()
    item["category_id"] = category_id
    item["draft"] = draft
    items.append(item)
    if notify:
        item["_notified"] = True
    return item

Petición de ejemplo con curl:

curl -X POST "http://127.0.0.1:8000/categories/3/items?notify=true&draft=false" \
  -H "Content-Type: application/json" \
  -d '{"name": "Teclado", "price": 79.99}'
  • Path category_id=3 → parte de la URL
  • Query notify=true, draft=false → después del ?
  • Body {"name": "Teclado", "price": 79.99} → payload JSON

FastAPI deserializa cada uno desde su fuente y los pasa a la función. No hay ambigüedad: cada parámetro tiene un origen definido.


Orden de prioridad: path, query, body

FastAPI resuelve los parámetros así:

  1. Path — segmentos en la URL
  2. Query — parámetros después de ?
  3. Body — contenido del request (JSON)

No hay conflicto: cada parámetro tiene una fuente clara. Lo que debes evitar es tener el mismo nombre en path y query (poco común y confuso).


Body con listas

Puedes recibir una lista en el body:

@app.post("/items/bulk")
def create_items_bulk(items_list: list[dict] = Body(...)):
    created = []
    for i, item in enumerate(items_list):
        item["id"] = len(items) + i + 1
        items.append(item)
        created.append(item)
    return {"created": len(created), "items": created}
curl -X POST http://127.0.0.1:8000/items/bulk \
  -H "Content-Type: application/json" \
  -d '[{"name": "A", "price": 10}, {"name": "B", "price": 20}]'

Body vacío o malformado

Si el cliente envía:

  • Body vacío en un endpoint que espera body → 422
  • JSON inválido (sintaxis incorrecta) → 422
  • Body con tipo incorrecto (ej. string donde se espera dict) → 422

FastAPI valida antes de ejecutar tu función y retorna detalles del error en español o inglés según configuración. La respuesta 422 incluye un array detail con cada error de validación; por ejemplo, si faltan campos requeridos o hay tipos incorrectos, verás exactamente qué campo falló.


Cuándo usar POST, PUT o PATCH

Elegir el método correcto mejora la semántica de tu API:

MétodoUsoBodyEfecto
POSTCrear recurso nuevoObligatorio, con datos del recursoCrea un nuevo ítem; normalmente retornas 201
PUTReemplazar recurso completoObligatorio, representa el recurso completoSustituye todo; el body es la nueva versión
PATCHActualización parcialOpcional o parcial; solo los campos a cambiarModifica solo los campos enviados

En REST, PUT es idempotente: llamarlo N veces con el mismo body produce el mismo resultado. PATCH también suele ser idempotente por campo. POST no es idempotente: cada llamada puede crear un recurso nuevo.

Ejemplo de respuesta 422

Cuando la validación falla, FastAPI retorna un JSON con detail que describe cada error:

{
  "detail": [
    {
      "loc": ["body"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

Si usas Pydantic, loc puede indicar campos específicos (ej. ["body", "price"]). Esto te ayuda a depurar qué envió mal el cliente.


Código completo de ejemplo

from fastapi import FastAPI, Body

app = FastAPI(
    title="Items API - Request Body",
    version="1.0.0",
)

items = [
    {"id": 1, "name": "Laptop", "price": 999.99, "category": "Electrónica"},
    {"id": 2, "name": "Mouse", "price": 29.99, "category": "Accesorios"},
]


def find_item(item_id: int):
    return next((i for i in items if i["id"] == item_id), None)


def generate_id():
    return max((i["id"] for i in items), default=0) + 1


# Solo body
@app.post("/items")
def create_item(item: dict = Body(...)):
    item["id"] = generate_id()
    items.append(item)
    return item


# Path + body
@app.put("/items/{item_id}")
def update_item(item_id: int, item: dict = Body(...)):
    existing = find_item(item_id)
    if existing is None:
        return {"error": "Item not found"}
    index = items.index(existing)
    items[index] = {"id": item_id, **item}
    return items[index]


# Path + body (parcial)
@app.patch("/items/{item_id}")
def partial_update_item(item_id: int, updates: dict = Body(...)):
    existing = find_item(item_id)
    if existing is None:
        return {"error": "Item not found"}
    existing.update({k: v for k, v in updates.items() if k != "id"})
    return existing

Errores comunes

Estos son los cinco errores más frecuentes al trabajar con el request body en FastAPI:

  1. Olvidar Body() con dict — Si usas item: dict sin Body(), FastAPI puede inferir el body correctamente en algunos casos, pero el comportamiento no es garantizado. Siempre usa item: dict = Body(...) para ser explícito.

  2. Content-Type incorrecto — El cliente debe enviar Content-Type: application/json. Si envía text/plain o application/x-www-form-urlencoded, FastAPI no parseará el body como JSON y obtendrás 422 o errores inesperados.

  3. Intentar enviar body en GET — HTTP semánticamente no define un body para GET. Algunos clientes y proxies lo ignoran. Usa POST, PUT o PATCH cuando necesites enviar datos en el body.

  4. Dict anidado sin modelo Pydantic — Con dict no hay validación de estructura. Si esperas {"address": {"city": "...", "zip": "..."}}, un cliente puede enviar cualquier cosa. Para estructuras complejas, migra a Pydantic (Módulo 4).

  5. No retornar el recurso creado — En POST, es buena práctica retornar el objeto creado (con su ID asignado) y usar status 201. Así el cliente recibe confirmación inmediata sin tener que hacer otro GET.


Conexión con Proyecto

En el proyecto de la Cápsula 06 combinarás path (product ID), query (filtros, paginación) y body (POST/PUT para crear y actualizar productos). Esta cápsula sienta las bases para ese flujo.


Troubleshooting

  • 422 al hacer POST — Verifica Content-Type: application/json y que el body sea JSON válido. En curl usa -H "Content-Type: application/json" y -d '{"key": "value"}' con comillas simples para evitar que el shell interprete las comillas dobles.

  • El body llega vacío o None — Asegúrate de usar Body(...) para requerido o Body(None) para opcional. Sin Body(), un dict puede no resolverse correctamente.

  • Conflicto path/query/body — No uses el mismo nombre para parámetros de distintas fuentes en un endpoint. Si tienes item_id en path y item_id en body, FastAPI no sabrá cuál usar.

  • PATCH sobrescribe todo — Usa existing.update(updates) para actualización parcial, no existing = updates. La segunda reemplaza la referencia; la primera modifica el dict in-place.

  • ID se pierde en PUT — Incluye el ID explícitamente: items[index] = {"id": item_id, **item}. El body del cliente puede no incluir el ID; el path es la fuente de verdad.

  • Lista vacía en bulk — Si POST /items/bulk recibe [], tu loop no hace nada. Decide si quieres retornar {"created": 0, "items": []} o 400 si requieres al menos un item.

  • Body como string en curl — En Windows, las comillas pueden complicarse. Usa -d "{\"name\": \"Laptop\"}" con escapes, o un archivo: -d @payload.json.


Tips para depuración

  • Usa /docs para probar endpoints con body: el editor JSON valida sintaxis y muestra errores de FastAPI. El esquema generado te indica qué campos espera cada endpoint.
  • Si obtienes 422, revisa el body de la respuesta: FastAPI indica el campo y el problema.
  • En desarrollo, imprime print(item) al inicio de tu función para ver qué llega; quítalo después.
  • Para estructuras anidadas complejas, considera migrar a Pydantic antes: los errores de validación serán más claros.
  • El campo loc en el error 422 indica la ruta del campo: ["body", "name"] significa que falta name en el body.
  • Con httpx en tests: response = client.post("/items", json={...}) — el parámetro json serializa y añade Content-Type automáticamente.

Ejemplo de error 422 típico

{
  "detail": [
    {
      "loc": ["body", "price"],
      "msg": "value is not a valid float",
      "type": "type_error.float"
    }
  ]
}

loc te dice dónde falló; msg explica el motivo. Úsalo para guiar al cliente al corregir la petición.


Ejercicios

Ejercicio 1: POST con body (Fácil)

Implementa POST /books que reciba {"title": "...", "author": "..."} y lo agregue a una lista con ID auto-generado.

Ver solución
from fastapi import FastAPI, Body

app = FastAPI()
books = []

@app.post("/books")
def create_book(book: dict = Body(...)):
    book["id"] = len(books) + 1
    books.append(book)
    return book

Ejercicio 2: PUT con path y body (Fácil)

Implementa PUT /books/{book_id} que actualice un libro existente usando los datos del body. Retorna error si no existe.

Ver solución
@app.put("/books/{book_id}")
def update_book(book_id: int, book: dict = Body(...)):
    existing = next((b for b in books if b["id"] == book_id), None)
    if existing is None:
        return {"error": "Book not found"}
    index = books.index(existing)
    books[index] = {"id": book_id, **book}
    return books[index]

Ejercicio 3: PATCH parcial (Medio)

Implementa PATCH /books/{book_id} que actualice solo los campos enviados en el body. Si envías {"title": "Nuevo título"}, solo cambia el título.

Ver solución
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
    existing = next((b for b in books if b["id"] == book_id), None)
    if existing is None:
        return {"error": "Book not found"}
    existing.update({k: v for k, v in updates.items() if k != "id"})
    return existing

Ejercicio 4: Body + query param (Medio)

Implementa POST /items que reciba el item en el body y un query param category: Optional[str] = None. Si category se envía, sobrescribe o complementa la categoría del item.

Ver solución
from typing import Optional

@app.post("/items")
def create_item(item: dict = Body(...), category: Optional[str] = None):
    if category is not None:
        item["category"] = category
    item["id"] = len(items) + 1
    items.append(item)
    return item

Ejercicio 5: Creación bulk (Medio)

Implementa POST /items/bulk que reciba una lista de items en el body y los cree todos. Retorna {"created": N, "items": [...]}.

Ver solución
@app.post("/items/bulk")
def create_items_bulk(items_list: list[dict] = Body(...)):
    created = []
    for i, item in enumerate(items_list):
        item["id"] = len(items) + i + 1
        items.append(item)
        created.append(item)
    return {"created": len(created), "items": created}

Ejercicio 6: Combinar todo (Difícil)

Implementa PUT /products/{product_id} con path param, body, y query param merge: bool = False. Si merge=True, hace actualización parcial (PATCH-style); si merge=False, reemplaza todo (PUT-style).

Ver solución
@app.put("/products/{product_id}")
def update_product(product_id: int, data: dict = Body(...), merge: bool = False):
    existing = next((p for p in products if p["id"] == product_id), None)
    if existing is None:
        return {"error": "Product not found"}
    index = products.index(existing)
    if merge:
        existing.update({k: v for k, v in data.items() if k != "id"})
        products[index] = existing
    else:
        products[index] = {"id": product_id, **data}
    return products[index]

Resumen

  • El request body contiene el payload JSON; se usa en POST, PUT, PATCH
  • Con dict necesitas Body() para indicar que el parámetro viene del body
  • Path, query y body pueden combinarse en el mismo endpoint sin conflicto
  • PATCH usa dict.update() para actualización parcial; PUT reemplaza el recurso completo
  • Pydantic (Módulo 4) reemplazará dict con modelos tipados y validación automática

Próxima cápsula: Headers, cookies y response — leer headers, configurar respuesta, status codes y response_model.


Recursos Adicionales

  1. FastAPI - Request Body - Documentación oficial del body
  2. FastAPI - Body with multiple parameters - Combinar body con query y path
  3. FastAPI - Body - Nested Models - Estructuras anidadas con Pydantic
  4. HTTP Message Body - MDN - Especificación HTTP del cuerpo del mensaje
  5. REST API Design - PUT vs PATCH - Diferencias semánticas entre PUT y PATCH
  6. JSON.org - Formato JSON: sintaxis y tipos soportados

Módulo 3, Cápsula 04 — FastAPI Fundamentals Guide