Module 2: Path Operations
Proyecto: CRUD Básico — API de Libros
Descripción del proyecto
Este proyecto es tu primer CRUD completo. Es un hito importante: pasas de endpoints GET estáticos a una API que crea, lee, actualiza y elimina recursos. Integras todo lo aprendido en el Módulo 2: construirás un CRUD sobre una colección de libros con endpoints GET, POST, PUT, PATCH y DELETE. Cada operación usa su status code apropiado, maneja el caso de "recurso no encontrado," y retorna respuestas JSON consistentes.
Este proyecto extiende tu Hello World API del Módulo 1 y se convierte en la base del Módulo 3. Los endpoints que crees aquí recibirán query parameters para filtrar, validación Pydantic para el body, y error handling profesional con HTTPException en las siguientes iteraciones. El objetivo es tener una API funcional que puedas probar de extremo a extremo desde /docs: crear un libro → listarlo → verlo por ID → actualizar → eliminar → confirmar que desapareció.
Al terminar, tendrás el patrón CRUD que usarás en cualquier API REST: tareas, usuarios, productos. Es la estructura que repite todo desarrollador backend.
Antes de empezar
Verifica que cumples los requisitos antes de empezar.
Prerrequisitos del Módulo 2:
- Cápsula 02: GET — listar y obtener por path parameter
- Cápsula 03: POST — crear recursos con request body y
Body(...) - Cápsula 04: PUT y PATCH — actualizar recursos completo vs parcial
- Cápsula 05: DELETE — eliminar recursos
Proyecto del Módulo 1 completado: Debes tener una Hello World API funcionando (o al menos saber crear endpoints GET y levantar uvicorn). Este proyecto extiende esa base.
Verificación rápida: Si entiendes la diferencia entre @app.get("/books") y @app.get("/books/{book_id}), y sabes que POST necesita Body(...) para recibir JSON, estás listo.
Estructura del proyecto
fastapi-fundamentals/
├── venv/
├── app/
│ ├── __init__.py
│ └── main.py ← Todo el código va aquí
├── requirements.txt ← fastapi, uvicorn[standard]
└── .gitignore
Ejecuta desde la raíz del proyecto: uvicorn app.main:app --reload
Objetivos
Al completar este proyecto:
- API que soporta las seis operaciones CRUD
- Status codes apropiados (200, 201)
- Mensajes descriptivos para "no encontrado"
- Flujo completo de vida de un recurso funcionando
- Código ejecutable y probado en
/docs
Modelo de datos
Cada libro tiene:
| Campo | Tipo | Descripción |
|---|---|---|
id | int | Identificador único, auto-generado |
title | str | Título del libro |
author | str | Nombre del autor |
year | int | Año de publicación |
genre | str | Género literario |
available | bool | Si está disponible |
Al menos 5 libros precargados con datos realistas.
Paso a paso guiado por operación CRUD
Implementa cada endpoint siguiendo esta guía. Después de cada uno, verifica en /docs.
GET /books — Listar todos
Concepto: Devuelve la colección completa. Es idempotente y seguro (no modifica datos).
Verifica en /docs: Haz "Try it out" → "Execute". Debes ver los 5 libros precargados. La respuesta es una lista directa (no un objeto con key books).
GET /books/{book_id} — Obtener por ID
Concepto: Path parameter book_id identifica el recurso. Si no existe, retornas un mensaje de error. FastAPI convierte automáticamente el path param a int.
Verifica en /docs:
- Con
book_id= 1: retorna "Cien Años de Soledad" - Con
book_id= 999: retorna{"error": "Book with id 999 not found"} - Prueba un ID decimal o texto: FastAPI devolverá 422 (validation error)
POST /books — Crear libro
Concepto: El body contiene los datos del nuevo libro. Body(...) obliga a enviar JSON. generate_id() asigna el ID. status_code=201 indica recurso creado.
Decisión clave: {"id": generate_id(), **book} — el spread operator añade el ID y permite que el cliente envíe solo los campos del libro (sin id).
Verifica en /docs: Envía {"title": "El Principito", "author": "Saint-Exupéry", "year": 1943, "genre": "Novela corta", "available": true}. La respuesta debe incluir el libro con id: 6 y status 201.
PUT /books/{book_id} — Actualizar completo
Concepto: Reemplaza todo el recurso. El cliente envía el objeto completo. El ID se preserva del path, no del body.
Decisión clave: books[index] = {"id": book_id, **book} — nunca confíes en el id del body; usa el del path.
Verifica en /docs: PUT a /books/1 con un body que cambie título, autor, etc. GET /books/1 debe mostrar el libro actualizado. PUT a /books/999 debe retornar error "not found".
PATCH /books/{book_id} — Actualizar parcial
Concepto: Solo actualiza los campos enviados. Los no enviados se mantienen. Ideal para "marcar como no disponible" sin reenviar todo.
Decisión clave: existing.update({k: v for k, v in updates.items() if k != "id"}) — ignoramos id para que nadie lo modifique.
Verifica en /docs: PATCH a /books/2 con {"available": false}. GET /books/2 debe mostrar available: false y el resto de campos intactos.
DELETE /books/{book_id} — Eliminar
Concepto: Elimina el recurso y retorna confirmación. books.remove(book) modifica la lista en memoria.
Verifica en /docs: DELETE /books/3. La respuesta debe ser {"message": "Book deleted", "id": 3}. GET /books/3 debe retornar "not found". GET /books ya no debe incluir Rayuela.
PUT vs PATCH — ¿Cuándo usar cada uno?
Pregunta común. Resumen práctico:
| Aspecto | PUT | PATCH |
|---|---|---|
| Semántica | Reemplaza el recurso completo | Actualiza solo campos enviados |
| Body | Debe incluir todos los campos | Solo los que quieres cambiar |
| Uso típico | Formulario de edición completo | Toggle, marcar como leído, etc. |
| Campos omitidos | Se pierden (o usas defaults) | Se mantienen |
| Ejemplo | Editar título, autor, año, género | Solo cambiar available a false |
En este proyecto usas PUT cuando el cliente envía el libro completo; PATCH cuando solo envía {"available": false} o {"title": "Nuevo título"}.
Endpoints requeridos
| Método | Ruta | Descripción | Status |
|---|---|---|---|
| GET | / | Info del servicio | 200 |
| GET | /books | Lista todos los libros | 200 |
| GET | /books/{book_id} | Obtiene libro por ID | 200 |
| POST | /books | Crea libro nuevo | 201 |
| PUT | /books/{book_id} | Actualiza libro completo | 200 |
| PATCH | /books/{book_id} | Actualiza campos específicos | 200 |
| DELETE | /books/{book_id} | Elimina libro | 200 |
Código completo
app/main.py
from fastapi import FastAPI, Body
app = FastAPI(
title="Books API",
description="API CRUD de gestión de libros. Módulo 2 — FastAPI Fundamentals.",
version="2.0.0",
)
books = [
{"id": 1, "title": "Cien Años de Soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico", "available": True},
{"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novela", "available": True},
{"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental", "available": True},
{"id": 4, "title": "La Casa de los Espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico", "available": False},
{"id": 5, "title": "Ficciones", "author": "Jorge Luis Borges", "year": 1944, "genre": "Cuentos", "available": True},
]
def generate_id() -> int:
if not books:
return 1
return max(book["id"] for book in books) + 1
def find_book(book_id: int):
return next((book for book in books if book["id"] == book_id), None)
@app.get("/")
def root():
return {"service": "Books API", "version": "2.0.0", "total_books": len(books)}
@app.get("/books")
def list_books():
return books
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = find_book(book_id)
if book is None:
return {"error": f"Book with id {book_id} not found"}
return book
@app.post("/books", status_code=201)
def create_book(book: dict = Body(...)):
new_book = {"id": generate_id(), **book}
books.append(new_book)
return new_book
@app.put("/books/{book_id}")
def update_book(book_id: int, book: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": f"Book with id {book_id} not found"}
index = books.index(existing)
books[index] = {"id": book_id, **book}
return books[index]
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": f"Book with id {book_id} not found"}
existing.update({k: v for k, v in updates.items() if k != "id"})
return existing
@app.delete("/books/{book_id}")
def delete_book(book_id: int):
book = find_book(book_id)
if book is None:
return {"error": f"Book with id {book_id} not found"}
books.remove(book)
return {"message": "Book deleted", "id": book_id}
Ejecutar
uvicorn app.main:app --reload
Verificación paso a paso
- GET /books — Debe mostrar 5 libros
- GET /books/1 — Debe mostrar "Cien Años de Soledad"
- GET /books/999 — Debe mostrar
{"error": "Book with id 999 not found"} - POST /books con body
{"title": "El Principito", "author": "Saint-Exupéry", "year": 1943, "genre": "Novela corta", "available": true}— Debe retornar el libro con id 6 - GET /books/6 — Debe mostrar El Principito
- PATCH /books/2 con body
{"available": false}— Don Quijote debe tener available: false - PUT /books/1 con body completo — Debe reemplazar todos los campos
- DELETE /books/3 — Debe retornar
{"message": "Book deleted", "id": 3} - GET /books/3 — Debe retornar error "not found"
- GET /books — Rayuela no debe aparecer en la lista
Comandos curl de referencia
# GET
curl -s http://127.0.0.1:8000/books | python -m json.tool
curl -s http://127.0.0.1:8000/books/1 | python -m json.tool
# POST
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "El Principito", "author": "Saint-Exupéry", "year": 1943, "genre": "Novela corta", "available": true}' \
| python -m json.tool
# PUT
curl -s -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Cien Años (Ed.)", "author": "GGM", "year": 1967, "genre": "Ficción", "available": false}'
# PATCH
curl -s -X PATCH http://127.0.0.1:8000/books/2 \
-H "Content-Type: application/json" \
-d '{"available": false}'
# DELETE
curl -s -X DELETE http://127.0.0.1:8000/books/3
Rúbrica de evaluación (100 puntos)
Endpoints (50 pts)
- (10) GET /books — Lista todos
- (10) GET /books/{id} — Obtiene por ID con "not found"
- (10) POST /books — Crea con ID auto-generado y status 201
- (8) PUT /books/{id} — Actualiza completo
- (7) PATCH /books/{id} — Actualiza parcial
- (5) DELETE /books/{id} — Elimina con confirmación
Datos y lógica (25 pts)
- (5) Al menos 5 libros precargados
- (5) find_book reutilizada
- (5) generate_id correcto
- (5) "Not found" en todos los endpoints que lo necesitan
- (5) Flujo completo funciona
Status codes y código (25 pts)
- (5) POST usa 201
- (5) GET, PUT, PATCH, DELETE usan 200
- (5) Metadata en FastAPI() (title, version)
- (5) Código limpio, sin duplicación
Flujo completo de vida de un recurso
Para validar que tu CRUD funciona de extremo a extremo, ejecuta este flujo en orden:
1. POST /books → Crear "El Principito" (retorna id: 6)
2. GET /books/6 → Verificar que existe
3. PATCH /books/6 → Cambiar available a false
4. GET /books/6 → Verificar el cambio parcial
5. PUT /books/6 → Actualizar todos los campos
6. GET /books/6 → Verificar actualización completa
7. DELETE /books/6 → Eliminar el libro
8. GET /books/6 → Verificar que retorna "not found"
9. GET /books → Verificar que no aparece en la lista
Este flujo simula exactamente lo que un frontend o app mobile haría contra tu API.
Testing completo — Flujo end-to-end con curl
Ejecuta este script de prueba con el servidor corriendo. Cada paso depende del anterior.
Paso 1 — Crear un libro:
curl -s -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", "available": true}'
Esperado: status 201, respuesta con el libro y id: 6 (o el siguiente disponible).
Paso 2 — Verificar que existe:
curl -s http://127.0.0.1:8000/books/6 | python -m json.tool
Esperado: el libro "Pedro Páramo" con todos sus campos.
Paso 3 — Actualizar parcialmente (solo available):
curl -s -X PATCH http://127.0.0.1:8000/books/6 \
-H "Content-Type: application/json" \
-d '{"available": false}'
Paso 4 — Verificar el cambio:
curl -s http://127.0.0.1:8000/books/6 | python -m json.tool
Esperado: available: false, resto igual.
Paso 5 — Actualizar completo con PUT:
curl -s -X PUT http://127.0.0.1:8000/books/6 \
-H "Content-Type: application/json" \
-d '{"title": "Pedro Páramo (Ed. Rev.)", "author": "Juan Rulfo", "year": 1955, "genre": "Realismo mágico", "available": true}'
Paso 6 — Verificar actualización completa:
curl -s http://127.0.0.1:8000/books/6
Esperado: título y género actualizados.
Paso 7 — Eliminar:
curl -s -X DELETE http://127.0.0.1:8000/books/6
Esperado: {"message": "Book deleted", "id": 6}.
Paso 8 — Verificar que ya no existe:
curl -s http://127.0.0.1:8000/books/6
Esperado: {"error": "Book with id 6 not found"}.
Paso 9 — Confirmar que no aparece en la lista:
curl -s http://127.0.0.1:8000/books | python -m json.tool | grep -A2 "Pedro Páramo" || echo "Correcto: Pedro Páramo ya no está en la lista"
Si todos los pasos devuelven lo esperado, tu CRUD funciona de extremo a extremo.
Manejo de errores: ¿Qué pasa cuando el libro no existe?
En GET, PUT, PATCH y DELETE con book_id, si el libro no existe debes retornar un mensaje claro. En este proyecto usamos un objeto {"error": "..."} con status 200. En el Módulo 5 usarás HTTPException(status_code=404) para respuestas 404 reales.
Comportamiento actual:
GET /books/999→{"error": "Book with id 999 not found"}(status 200)PUT /books/999con body → mismo mensajePATCH /books/999con body → mismo mensajeDELETE /books/999→ mismo mensaje
Patrón aplicado: find_book() devuelve None si no existe. En cada endpoint compruebas if book is None y retornas el mensaje de error antes de continuar. Evita duplicar la lógica de búsqueda.
Mejora futura (Módulo 5): Lanzar raise HTTPException(status_code=404, detail="Book not found") para que el cliente reciba un 404 HTTP real y pueda distinguir "no encontrado" de "éxito con mensaje".
Troubleshooting
Si algo no funciona, revisa estos casos frecuentes.
Problema 1: POST retorna 422 o el body llega vacío
Causa: Falta Body(...) o el Content-Type no es application/json.
Solución: Usa book: dict = Body(...) en la firma de la función. En curl incluye -H "Content-Type: application/json". En /docs, el body se envía automáticamente como JSON.
Problema 2: PUT borra el ID o sobrescribe con datos incorrectos
Causa: Asignaste books[index] = book en lugar de preservar el ID del path.
Solución: Siempre books[index] = {"id": book_id, **book}. El book_id viene del path, no del body.
Problema 3: PATCH se comporta como PUT (borra campos no enviados)
Causa: Reemplazaste el objeto completo en lugar de actualizar solo los campos enviados.
Solución: Usa existing.update(updates) — no reasignes existing = updates. Filtra id si viene en el body: existing.update({k: v for k, v in updates.items() if k != "id"}).
Problema 4: DELETE no elimina (el libro sigue en GET /books)
Causa: Retornaste el mensaje pero no llamaste a books.remove(book).
Solución: Primero books.remove(book), luego return {"message": "Book deleted", "id": book_id}.
Problema 5: Ruta /books/latest devuelve "Book with id latest not found"
Causa: FastAPI interpreta "latest" como book_id porque /books/{book_id} captura cualquier string.
Solución: Declara rutas estáticas antes de la dinámica. Por ejemplo: @app.get("/books/latest") antes de @app.get("/books/{book_id}").
Checklist de completitud
Antes de dar por terminado el proyecto, verifica:
- GET / retorna info del servicio con total_books
- GET /books retorna la lista completa
- GET /books/{id} retorna un libro o error "not found"
- POST /books crea con ID auto-generado y status 201
- PUT /books/{id} reemplaza todos los campos
- PATCH /books/{id} solo modifica campos enviados
- DELETE /books/{id} elimina y retorna confirmación
- Flujo completo (crear → leer → actualizar → eliminar) funciona sin errores
Errores comunes
POST no recibe el body
# ❌ Sin Body()
def create_book(book: dict):
# ✅ Con Body()
def create_book(book: dict = Body(...)):
PUT borra el ID
# ❌ Se pierde el ID
books[index] = book
# ✅ Preserva el ID
books[index] = {"id": book_id, **book}
PATCH sobrescribe todo como PUT
# ❌ Reemplaza todo
existing = updates
# ✅ Solo actualiza campos proporcionados
existing.update(updates)
DELETE no elimina realmente
# ❌ Solo retorna, no modifica la lista
return {"message": "deleted"}
# ✅ Remueve de la lista
books.remove(book)
return {"message": "Book deleted", "id": book_id}
Orden de rutas
Si agregas rutas como /books/latest o /books/count, decláralas antes de /books/{book_id}. FastAPI evalúa en orden.
Datos se pierden al reiniciar
Comportamiento esperado. Los datos viven en memoria. Al reiniciar uvicorn, la lista vuelve a su estado inicial. Para persistencia real necesitas una base de datos (guías posteriores del path).
Qué aprendiste
Al completar este proyecto dominas:
- CRUD completo: Crear, leer, actualizar y eliminar un recurso usando los verbos HTTP correctos
- Path parameters: Uso de
{book_id}en la ruta y conversión automática a tipos (int) - Request body:
Body(...)para recibir JSON en POST, PUT y PATCH - Status codes: 201 para POST (recurso creado), 200 para el resto
- Funciones auxiliares:
find_book()ygenerate_id()para evitar duplicación - Manejo de "no encontrado": Mensajes consistentes cuando el recurso no existe
- PUT vs PATCH: Cuándo reemplazar todo (PUT) y cuándo actualizar solo campos (PATCH)
Estos patrones son la base de cualquier API REST. Los usarás en tareas, usuarios, productos y cualquier otro recurso.
Manejo de errores: recurso no encontrado
Cuando solicitas un libro por ID que no existe (por ejemplo GET /books/999), tu API retorna {"error": "Book with id 999 not found"} con status 200. Eso es consistente pero no ideal: en REST, "no encontrado" suele ir con status 404 Not Found.
Por qué usamos 200 aquí: En el Módulo 2 trabajas sin HTTPException. FastAPI devuelve 200 por defecto. Es aceptable para este proyecto; en el Módulo 5 aprenderás a usar HTTPException(status_code=404, detail="...") para devolver 404 real.
Qué debe pasar en cada endpoint:
- GET /books/{id}: Si no existe → mensaje de error en el body, el cliente puede comprobar
if "error" in response - PUT /books/{id}: Si no existe → no crear uno nuevo; retornar error. PUT es idempotente sobre un recurso conocido
- PATCH /books/{id}: Igual que PUT — no inventar recursos
- DELETE /books/{id}: Si no existe → puedes retornar error o aceptar que "ya no existe" y retornar éxito. En este proyecto retornamos error para consistencia
Patrón aplicado: find_book() devuelve None cuando no hay coincidencia. Todos los endpoints que usan find_book comprueban if book is None y retornan el mensaje de error antes de continuar.
Patrones aplicados
- find_book reutilizada: Evita duplicar la búsqueda por ID en PUT, PATCH y DELETE
- generate_id centralizado: Un solo lugar para la lógica de IDs
- Spread operator:
{"id": generate_id(), **book}para crear el nuevo libro limpiamente - Mismo path, distintos verbos:
/books/{book_id}con GET, PUT, PATCH, DELETE — REST en acción
Qué aprendiste
Al completar este proyecto dominas:
- CRUD completo: GET (listar + obtener por ID), POST, PUT, PATCH, DELETE sobre un recurso
- Path parameters: FastAPI convierte
{book_id}aintautomáticamente - Request body con Body(): Recibir JSON en POST, PUT y PATCH
- Status codes: 201 para creación, 200 para el resto
- Funciones auxiliares:
find_bookygenerate_idreutilizadas - Manejo de "not found": Patrón consistente en GET, PUT, PATCH, DELETE
- PUT vs PATCH: Cuándo reemplazar completo vs actualizar parcial
Estos skills son la base de cualquier API REST. El mismo patrón aplica a tareas, usuarios, productos.
Reflexión: Progreso del Módulo 2
Al empezar el Módulo 2 tenías solo endpoints GET estáticos. Ahora tienes una API que gestiona un recurso completo: crear, leer, actualizar y eliminar libros. La misma estructura (path + verbo HTTP + lógica) aplica a cualquier recurso — tareas, usuarios, productos. Dominar este patrón es la base del desarrollo backend con APIs REST.
Resumen
- CRUD completo: GET (list + detail), POST, PUT, PATCH, DELETE
- Status 201 para POST, 200 para el resto
- Funciones auxiliares:
find_book,generate_id - Manejo básico de "not found" con mensajes descriptivos
- Flujo de vida: crear → leer → actualizar → eliminar
El Módulo 3 agregará query parameters para filtrar. El Módulo 4 agregará validación Pydantic. El Módulo 5 agregará HTTPException para 404 real. Tu base CRUD está lista.
Conexión con el siguiente módulo
Tu CRUD de libros es funcional. En el Módulo 3 agregarás:
- Query parameters:
GET /books?genre=Novelapara filtrar - Parámetros opcionales con valores por defecto
- Validación de rangos y formatos en parámetros
En el Módulo 4, Pydantic reemplazará los dict con modelos tipados. En el Módulo 5, HTTPException reemplazará los mensajes de error con status 404 real.
Ideas para extender (opcional)
Si terminas rápido y quieres practicar más:
GET /books/count— Retorna el número total de librosGET /books/available— Lista solo los disponibles (ruta estática antes de{book_id})GET /books/latest— Retorna el libro más reciente por añoPOST /bookscon validación de título no vacío y año razonable
Recuerda: rutas estáticas (/books/count, /books/latest) deben ir antes de /books/{book_id}.
¿Qué sigue?
Módulo 3: Request y Response — Query parameters para filtrar (GET /books?genre=Novela), parámetros opcionales, y manejo avanzado del request. Tu API de libros está lista para evolucionar.
Recursos Adicionales
- FastAPI - Path Operations - Referencia oficial
- FastAPI - Request Body - Request bodies
- FastAPI - Response Status Code - Status codes
- HTTP Methods - REST API Tutorial - Convenciones REST
- FastAPI - Path Parameters - Path params y validación
- REST API Design - Resource Naming - Convenciones de rutas REST
Módulo 2, Cápsula 06 — FastAPI Fundamentals Guide