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 /docs refleja 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 available sin 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:

CampoTipoDescripción
idintIdentificador único, auto-generado
titlestrTítulo del libro
authorstrNombre del autor
yearintAño de publicación
genrestrGénero literario
availableboolSi 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

  1. Abre http://127.0.0.1:8000/docs
  2. Verifica que ves 7 endpoints organizados bajo "General" y "Books"
  3. Ejecuta cada operación en orden usando "Try it out"
  4. 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

PasoErrorSolución
POST422 Unprocessable EntityVerifica que el JSON del body es válido
GET por IDRetorna el libro incorrectoVerifica que buscas por id, no por índice
DELETEEl libro sigue apareciendoVerifica que usas books.remove(), no solo find_book()
PATCHSe borran campos no enviadosVerifica 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:

ConceptoNivel esperado
@app.get, @app.post, etc.Sabes crear endpoints con cualquier verbo HTTP
Body(...)Sabes recibir JSON en request body
Path parametersSabes extraer IDs y otros valores de la URL
Status codesSabes usar 200 y 201 en los decoradores
Datos en memoriaSabes operar CRUD sobre una lista de dicts
find/generate helpersSabes crear funciones auxiliares reutilizables
/docs testingSabes 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 GETGET, POST, PUT, PATCH, DELETE
Respuestas estáticasDatos dinámicos en memoria
Sin estadoEstado que cambia (create/update/delete)
Path params básicosPath params + request body
Solo status 200Status 200, 201
Sin funciones auxiliaresfind_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

  1. FastAPI - Path Operations - Referencia oficial de decoradores y path operations
  2. FastAPI - Request Body - Cómo FastAPI maneja request bodies
  3. FastAPI - Response Status Code - Status codes en decoradores
  4. HTTP Methods - REST API Tutorial - Convenciones REST para cada verbo
  5. MDN - HTTP Status Codes - Referencia completa de status codes
  6. curl Documentation - Referencia de flags de curl para testing
  7. FastAPI - Path Operation Configuration - Opciones avanzadas de configuración de endpoints