Module 2: Path Operations
Proyecto: CRUD Endpoints Completo
Descripción del proyecto
En este proyecto integras todo lo que aprendiste en el Módulo 2: construirás un CRUD completo 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.
No es un ejercicio aislado — este proyecto extiende tu proyecto del Módulo 1 y se convierte en la base del Módulo 3. Los endpoints que crees aquí recibirán query parameters (Módulo 3), validación Pydantic (Módulo 4) y error handling profesional (Módulo 5) en las siguientes iteraciones. Cada módulo agrega una capa sin reescribir lo que ya hiciste.
El objetivo es que tengas un API funcional con las 6 operaciones CRUD, datos realistas, y que puedas probar todo el flujo completo desde /docs: crear un libro → listarlo → verlo por ID → actualizar su título → eliminar → confirmar que desapareció.
Objetivo del proyecto
Construir una API de gestión de libros con CRUD completo usando datos en memoria.
Al completar este proyecto:
- ✅ Tu API soporta las 6 operaciones CRUD estándar
- ✅ Cada operación usa el status code apropiado
- ✅ Los errores de "no encontrado" retornan mensajes descriptivos
- ✅ Puedes ejecutar el flujo completo de vida de un recurso (crear → leer → actualizar → eliminar)
- ✅ La documentación en
/docsrefleja todas las operaciones con tags organizados
Especificaciones técnicas
Stack
- Framework: FastAPI
- Servidor: uvicorn con hot reload
- Almacenamiento: Lista de diccionarios en memoria
- Dependencias:
fastapi,uvicorn[standard]
Estructura del proyecto
fastapi-fundamentals/
├── venv/
├── app/
│ ├── __init__.py
│ └── main.py ← todo el código va aquí
├── requirements.txt
└── .gitignore
¿Por qué este proyecto?
Una API de libros es un dominio que permite practicar todos los patrones CRUD de forma natural:
- Crear un libro nuevo es intuitivo — necesitas título, autor, año, género
- Listar libros tiene sentido — una librería tiene muchos libros
- Actualizar un libro parcialmente es realista — quieres cambiar
availablesin reenviar todo - Eliminar un libro es directo — el libro se descontinúa
Además, los campos son variados (strings, int, bool), lo que prepara el terreno para la validación Pydantic del Módulo 4.
Modelo de datos
Cada libro tiene estos campos:
| 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 para préstamo |
{
"id": 1,
"title": "Cien Años de Soledad",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Realismo mágico",
"available": True
}
Datos iniciales
Tu API inicia con al menos 5 libros precargados con datos realistas para poder probar GET inmediatamente.
Endpoints obligatorios
1. GET /books — Listar todos los libros
GET /books
Status: 200
Response:
[
{"id": 1, "title": "Cien Años de Soledad", ...},
{"id": 2, "title": "Don Quijote", ...},
...
]
2. GET /books/{book_id} — Obtener un libro por ID
GET /books/1
Status: 200
Response:
{"id": 1, "title": "Cien Años de Soledad", "author": "Gabriel García Márquez", ...}
GET /books/999
Status: 200 (por ahora — Module 5 usará 404)
Response:
{"error": "Book with id 999 not found"}
3. POST /books — Crear un libro nuevo
POST /books
Status: 201
Body:
{
"title": "El Principito",
"author": "Antoine de Saint-Exupéry",
"year": 1943,
"genre": "Novela corta",
"available": true
}
Response:
{"id": 6, "title": "El Principito", ...}
4. PUT /books/{book_id} — Actualizar un libro completo
PUT /books/1
Status: 200
Body:
{
"title": "Cien Años de Soledad (Edición Especial)",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Realismo mágico",
"available": false
}
Response:
{"id": 1, "title": "Cien Años de Soledad (Edición Especial)", ...}
5. PATCH /books/{book_id} — Actualizar campos específicos
PATCH /books/2
Status: 200
Body:
{
"available": false
}
Response:
{"id": 2, "title": "Don Quijote", ..., "available": false}
6. DELETE /books/{book_id} — Eliminar un libro
DELETE /books/3
Status: 200
Response:
{"message": "Book deleted", "id": 3}
DELETE /books/999
Status: 200
Response:
{"error": "Book with id 999 not found"}
Código completo comentado
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",
)
# Datos en memoria — se pierden al reiniciar el servidor
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:
"""Genera el siguiente ID disponible."""
if not books:
return 1
return max(book["id"] for book in books) + 1
def find_book(book_id: int) -> dict | None:
"""Busca un libro por ID. Retorna None si no existe."""
return next((book for book in books if book["id"] == book_id), None)
# --- Endpoints de lectura ---
@app.get("/", tags=["General"], summary="Root")
def root():
"""Información del servicio."""
return {
"service": "Books API",
"version": "2.0.0",
"total_books": len(books),
}
@app.get("/books", tags=["Books"], summary="List all books", status_code=200)
def list_books():
"""Retorna la lista completa de libros."""
return books
@app.get("/books/{book_id}", tags=["Books"], summary="Get book by ID", status_code=200)
def get_book(book_id: int):
"""Retorna un libro por su ID. Si no existe, retorna error."""
book = find_book(book_id)
if book is None:
return {"error": f"Book with id {book_id} not found"}
return book
# --- Endpoint de creación ---
@app.post("/books", tags=["Books"], summary="Create a new book", status_code=201)
def create_book(book: dict = Body(...)):
"""
Crea un libro nuevo con ID auto-generado.
Envía un JSON con: title, author, year, genre, available.
"""
new_book = {
"id": generate_id(),
**book,
}
books.append(new_book)
return new_book
# --- Endpoints de actualización ---
@app.put("/books/{book_id}", tags=["Books"], summary="Full update", status_code=200)
def update_book(book_id: int, book: dict = Body(...)):
"""
Reemplaza todos los campos de un libro existente.
Envía TODOS los campos (title, author, year, genre, available).
"""
existing_book = find_book(book_id)
if existing_book is None:
return {"error": f"Book with id {book_id} not found"}
# Reemplaza todos los campos manteniendo el ID
index = books.index(existing_book)
books[index] = {"id": book_id, **book}
return books[index]
@app.patch(
"/books/{book_id}",
tags=["Books"],
summary="Partial update",
status_code=200,
)
def partial_update_book(book_id: int, updates: dict = Body(...)):
"""
Actualiza solo los campos proporcionados.
Envía solo los campos que quieres cambiar.
"""
existing_book = find_book(book_id)
if existing_book is None:
return {"error": f"Book with id {book_id} not found"}
# Solo actualiza los campos proporcionados
existing_book.update(updates)
return existing_book
# --- Endpoint de eliminación ---
@app.delete("/books/{book_id}", tags=["Books"], summary="Delete book", status_code=200)
def delete_book(book_id: int):
"""Elimina un libro por su ID."""
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
Sigue este flujo para verificar que todo funciona. Usa /docs o curl.
Paso 1: Verificar datos iniciales
curl -s http://127.0.0.1:8000/books | python -m json.tool
# Debe mostrar 5 libros
Paso 2: Obtener un libro por ID
curl -s http://127.0.0.1:8000/books/1 | python -m json.tool
# Debe mostrar "Cien Años de Soledad"
curl -s http://127.0.0.1:8000/books/999 | python -m json.tool
# Debe mostrar {"error": "Book with id 999 not found"}
Paso 3: Crear un libro nuevo
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "El Principito", "author": "Antoine de Saint-Exupéry", "year": 1943, "genre": "Novela corta", "available": true}' \
| python -m json.tool
# Debe mostrar el libro con id: 6
Paso 4: Verificar que se creó
curl -s http://127.0.0.1:8000/books/6 | python -m json.tool
# Debe mostrar "El Principito" con id 6
Paso 5: Actualización completa (PUT)
curl -s -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Cien Años de Soledad (Edición Especial)", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico", "available": false}' \
| python -m json.tool
# Debe mostrar el libro actualizado con available: false
Paso 6: Actualización parcial (PATCH)
curl -s -X PATCH http://127.0.0.1:8000/books/2 \
-H "Content-Type: application/json" \
-d '{"available": false}' \
| python -m json.tool
# Debe mostrar Don Quijote con available: false, demás campos intactos
Paso 7: Eliminar un libro
curl -s -X DELETE http://127.0.0.1:8000/books/3 | python -m json.tool
# Debe mostrar {"message": "Book deleted", "id": 3}
Paso 8: Verificar eliminación
curl -s http://127.0.0.1:8000/books | python -m json.tool
# Rayuela (id: 3) no debe aparecer en la lista
# Debe haber 5 libros (5 originales - 1 eliminado + 1 creado)
Paso 9: Verificar root con total actualizado
curl -s http://127.0.0.1:8000/ | python -m json.tool
# total_books debe ser 5
Paso 10: Probar el flujo completo desde /docs
- Abre
http://127.0.0.1:8000/docs - Verifica que ves 7 endpoints organizados bajo "General" y "Books"
- Ejecuta cada operación en orden usando "Try it out"
- Verifica que las respuestas coinciden con las especificaciones de arriba
Tip: Swagger UI recuerda los valores que ingresaste. Puedes usarlo como herramienta de testing rápido durante desarrollo.
Problemas frecuentes en la verificación
| Paso | Error | Solución |
|---|---|---|
| POST | 422 Unprocessable Entity | Verifica que el JSON del body es válido |
| GET por ID | Retorna el libro incorrecto | Verifica que buscas por id, no por índice |
| DELETE | El libro sigue apareciendo | Verifica que usas books.remove(), no solo find_book() |
| PATCH | Se borran campos no enviados | Verifica que usas .update(), no reemplazo completo |
Checklist de completitud
Endpoints (6):
- [ ] GET / — Info del servicio con total de libros
- [ ] GET /books — Lista todos los libros
- [ ] GET /books/{book_id} — Obtiene libro por ID
- [ ] POST /books — Crea libro nuevo con ID auto-generado
- [ ] PUT /books/{book_id} — Actualiza libro completo
- [ ] PATCH /books/{book_id} — Actualiza campos específicos
- [ ] DELETE /books/{book_id} — Elimina libro
Status codes:
- [ ] GET retorna 200
- [ ] POST retorna 201
- [ ] PUT y PATCH retornan 200
- [ ] DELETE retorna 200
Funcionalidad:
- [ ] Datos iniciales: al menos 5 libros precargados
- [ ] IDs se auto-generan en POST
- [ ] "Not found" retorna mensaje descriptivo
- [ ] PUT reemplaza todos los campos
- [ ] PATCH solo modifica campos enviados
- [ ] DELETE remueve el libro de la lista
- [ ] Flujo completo funciona (crear → leer → actualizar → eliminar)
Documentación:
- [ ] Título personalizado en /docs
- [ ] Endpoints organizados con tags
- [ ] Docstrings en cada endpoint
Código:
- [ ] Función find_book reutilizada en todos los endpoints
- [ ] Función generate_id para IDs automáticos
- [ ] Código limpio y legible
Flujo completo de vida de un recurso
Para entender cómo las operaciones se conectan, sigue el ciclo de vida completo de un libro:
1. POST /books → Crear "El Principito" (id: 6)
2. GET /books/6 → Verificar que existe
3. PATCH /books/6 → Cambiar available a false
4. GET /books/6 → Verificar el cambio
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 es exactamente lo que un frontend o una app mobile haría contra tu API. Poder ejecutar este ciclo completo desde /docs sin errores es la prueba definitiva de que tu CRUD funciona.
Errores comunes
Error 1: POST no recibe el body
Causa: Olvidaste Body(...) en el parámetro.
# ❌ Esto no funciona — FastAPI no sabe de dónde leer book
def create_book(book: dict):
...
# ✅ Esto funciona — Body() indica que viene del request body
def create_book(book: dict = Body(...)):
...
Error 2: PUT borra el ID del libro
Causa: Reemplazaste todo el diccionario sin preservar el ID.
# ❌ Se pierde el ID original
books[index] = book
# ✅ Preserva el ID
books[index] = {"id": book_id, **book}
Error 3: PATCH sobrescribe todo como PUT
Causa: Usaste asignación directa en lugar de .update().
# ❌ Reemplaza todo (comportamiento PUT)
existing_book = updates
# ✅ Solo actualiza campos proporcionados (comportamiento PATCH)
existing_book.update(updates)
Error 4: DELETE retorna body pero status es 204
Causa: Status 204 (No Content) no permite body en la respuesta.
# ❌ Conflicto: 204 dice "no body" pero retornas un dict
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
...
return {"message": "deleted"} # No se envía con 204
# ✅ Usa 200 si quieres retornar confirmación
@app.delete("/books/{book_id}", status_code=200)
def delete_book(book_id: int):
...
return {"message": "Book deleted", "id": book_id}
Error 5: Datos se pierden al reiniciar
Causa: Esto es esperado. Los datos viven en memoria. Al reiniciar uvicorn (o al hacer hot reload si el módulo se reimporta), la lista se reinicia.
Solución: Esto se resuelve con bases de datos reales en guías posteriores del path. Por ahora es comportamiento completamente normal y esperado — no es un bug, es una limitación deliberada del almacenamiento en memoria.
Nota: Hot reload con --reload reimporta el módulo, lo que reinicia la lista books a sus valores iniciales. Esto es útil para testing (siempre empiezas con datos limpios) pero no es persistencia real.
Rúbrica de evaluación (100 puntos)
Endpoints (45 puntos)
- (8 pts) GET /books — Lista todos los libros
- (8 pts) GET /books/{id} — Obtiene por ID con "not found"
- (9 pts) POST /books — Crea con ID auto-generado y status 201
- (7 pts) PUT /books/{id} — Actualiza completo
- (7 pts) PATCH /books/{id} — Actualiza parcial
- (6 pts) DELETE /books/{id} — Elimina con confirmación
Datos y lógica (25 puntos)
- (5 pts) Al menos 5 libros precargados con datos realistas
- (5 pts) find_book reutilizada en GET, PUT, PATCH, DELETE
- (5 pts) generate_id funciona correctamente
- (5 pts) "Not found" manejado en todos los endpoints que lo necesitan
- (5 pts) Flujo completo funciona de principio a fin
Documentación y código (20 puntos)
- (5 pts) Metadata personalizada en FastAPI()
- (5 pts) Tags agrupando endpoints
- (5 pts) Docstrings descriptivos
- (5 pts) Código limpio, funciones auxiliares, sin duplicación
Status codes (10 puntos)
- (3 pts) POST usa 201
- (4 pts) GET, PUT, PATCH usan 200
- (3 pts) DELETE usa 200 con confirmación
Extra credit (+10 puntos)
- (+3 pts) GET /books/count retorna el número total de libros
- (+4 pts) GET /books/genres retorna una lista de géneros únicos disponibles
- (+3 pts) Root endpoint muestra estadísticas dinámicas (total, disponibles, géneros)
Ideas para extender (opcional)
Si terminas rápido y quieres practicar más:
@app.get("/books/stats", tags=["Books"], summary="Book statistics")
def book_stats():
"""Estadísticas de la colección de libros."""
total = len(books)
available = sum(1 for b in books if b.get("available", False))
genres = list(set(b["genre"] for b in books))
authors = list(set(b["author"] for b in books))
return {
"total_books": total,
"available": available,
"unavailable": total - available,
"unique_genres": len(genres),
"genres": sorted(genres),
"unique_authors": len(authors),
}
@app.get("/books/latest", tags=["Books"], summary="Latest book")
def latest_book():
"""Retorna el libro más reciente por año de publicación."""
if not books:
return {"error": "No books available"}
latest = max(books, key=lambda b: b["year"])
return latest
Recuerda: /books/stats y /books/latest deben declararse antes de /books/{book_id} para que FastAPI no las interprete como path parameters. Este es el pitfall de orden de rutas que cubriste en la Cápsula 02.
Patrones que aplicaste
Este proyecto te hizo practicar varios patrones de desarrollo backend que son estándar en la industria:
1. Funciones auxiliares reutilizables
def find_book(book_id: int) -> dict | None:
return next((book for book in books if book["id"] == book_id), None)
En lugar de duplicar la búsqueda por ID en cada endpoint, centralizas la lógica en una función. Esto reduce bugs y facilita cambios futuros (cuando pases a una base de datos, solo cambias find_book).
2. Spread operator para preservar IDs
new_book = {"id": generate_id(), **book}
El **book "desempaqueta" el diccionario recibido y "id": generate_id() agrega el ID auto-generado. Es un patrón limpio que verás en código profesional.
3. Separación de rutas por verbo
La misma ruta /books/{book_id} tiene comportamientos diferentes según el verbo HTTP. FastAPI distingue GET, PUT, PATCH y DELETE en la misma ruta — eso es REST en acción.
4. Manejo básico de errores
Retornar {"error": "Book not found"} no es lo ideal (debería ser un status 404), pero establece el patrón de "verificar antes de operar." En el Módulo 5, reemplazarás estos dicts con HTTPException(status_code=404, detail="Book not found") — la transición será natural porque el patrón mental ya está establecido.
La clave es: siempre verifica que el recurso existe antes de operar sobre él. Este principio aplica en cualquier API, con cualquier framework, en cualquier lenguaje.
Conexión con el siguiente módulo
Tu CRUD de libros es funcional pero tiene limitaciones que el Módulo 3 resolverá:
Lo que ya tienes:
- CRUD completo con 6 operaciones
- Datos en memoria con IDs automáticos
- Status codes apropiados
- Manejo básico de "not found"
Lo que agregarás en Módulo 3 (Request y Response):
- Query parameters:
GET /books?genre=Novela&year=1967— filtrar libros - Optional parameters:
GET /books?available=true— filtros opcionales con defaults - Parámetros con validación: Limitar rangos, longitudes, formatos
- Request body más sofisticado: Combinar path params + body
Lo que agregarás en Módulo 4 (Pydantic):
- Modelos que validan automáticamente el body del POST y PUT
- Separación de modelos para request vs response
- Validación de tipos, rangos, formatos
Lo que agregarás en Módulo 5 (Error Handling):
HTTPException(status_code=404, detail="Book not found")en lugar de dicts con error- Status code 404 real para "not found"
- Custom exception handlers
- Respuestas de error consistentes
Evolución visual del código:
Módulo 2 (ahora): Módulo 6 (final):
────────────────── ─────────────────
dict como body → Pydantic models
{"error": "not found"} → HTTPException(404)
GET /books (sin filtros) → GET /tasks?status=pending
status 200 para errors → status codes correctos
sin validación → validación automática
Lo que llevas al Módulo 3:
| Concepto | Nivel esperado |
|---|---|
@app.get, @app.post, etc. | Sabes crear endpoints con cualquier verbo HTTP |
Body(...) | Sabes recibir JSON en request body |
| Path parameters | Sabes extraer IDs y otros valores de la URL |
| Status codes | Sabes usar 200 y 201 en los decoradores |
| Datos en memoria | Sabes operar CRUD sobre una lista de dicts |
| find/generate helpers | Sabes crear funciones auxiliares reutilizables |
| /docs testing | Sabes probar todos los verbos desde Swagger UI |
Reflexión: Lo que ya dominas después de 2 módulos
Detente y nota el progreso desde el Módulo 1:
| Módulo 1 (Hello World) | Módulo 2 (CRUD) |
|---|---|
| Solo endpoints GET | GET, POST, PUT, PATCH, DELETE |
| Respuestas estáticas | Datos dinámicos en memoria |
| Sin estado | Estado que cambia (create/update/delete) |
| Path params básicos | Path params + request body |
| Solo status 200 | Status 200, 201 |
| Sin funciones auxiliares | find_book, generate_id |
Tu API pasó de responder "Hello World" a gestionar una colección completa de libros. Eso es progreso real.
Resumen
En este proyecto integraste las operaciones CRUD fundamentales:
- GET para listar todos los libros y obtener uno por ID
- POST con
Body(...)para recibir JSON y crear recursos con ID auto-generado - PUT para reemplazar un recurso completo preservando el ID
- PATCH con
.update()para modificar solo campos específicos - DELETE para eliminar recursos con confirmación
- Status codes apropiados: 200 (OK), 201 (Created)
- Funciones auxiliares (
find_book,generate_id) para evitar duplicación - Manejo básico de "not found" con respuestas descriptivas
Tu Books API es funcional y completa para su nivel. Los módulos 3-5 le agregarán filtros, validación y error handling profesional sin reescribir lo que ya tienes.
El dominio de CRUD es fundamental — si puedes construir un CRUD limpio y funcional, puedes construir el 80% de las APIs que el mundo necesita. Lo que sigue es refinamiento: mejores parámetros de entrada, mejor validación de datos, mejor manejo de errores, y mejor organización del código.
Módulo 2 completado. Siguiente parada: Request y Response — donde tus endpoints aprenden a filtrar, paginar y manejar datos de entrada de forma sofisticada.
Recursos para el proyecto
- FastAPI - Path Operations - Referencia oficial de decoradores y path operations
- FastAPI - Request Body - Cómo FastAPI maneja request bodies
- FastAPI - Response Status Code - Status codes en decoradores
- HTTP Methods - REST API Tutorial - Convenciones REST para cada verbo
- MDN - HTTP Status Codes - Referencia completa de status codes
- curl Documentation - Referencia de flags de curl para testing
- FastAPI - Path Operation Configuration - Opciones avanzadas de configuración de endpoints