Saltar al contenido
🐍 Curso de Python
/

Módulo 5 · Lección 30 de 30

Proyecto final: una API completa

Reunir las veintinueve lecciones en una API que funciona, con los fallos silenciosos del curso ya cerrados, y el bucle infinito que apareció al montarla.

⏱ 20 min de lectura

Última lección del curso. Toca juntarlo todo: una API con autenticación, permisos por papel, paginación por cursor, filtros, errores propios y pruebas.

Lo interesante no es el código, que a estas alturas no sorprende. Es que los fallos silenciosos que el curso fue encontrando están cerrados en él, y que montarlo destapó uno nuevo que ninguna lección había visto.

La forma del proyecto#

Salida en pantalla
api/
  app/
    ajustes.py         los ajustes validados        (lección 25)
    modelos.py         SQLAlchemy                   (lección 26)
    esquemas.py        los modelos de pydantic      (lección 25)
    seguridad.py       contraseñas y tokens         (lección 27)
    dependencias.py    sesión, usuario, papeles     (lecciones 26 y 27)
    errores.py         los manejadores propios      (lección 23)
    routers/
      libros.py
      cuentas.py
    main.py            la aplicación
  conftest.py          los accesorios de prueba     (lección 28)
  test_catalogo.py     las pruebas

La separación que importa es la de modelos.py y esquemas.py: lo que hay en la base de datos y lo que sale por la API son cosas distintas, que es la primera frase de la lección 11 y sigue siendo la decisión más importante.

Los errores, arreglados de entrada#

Lo primero que se escribe, porque cambiarlo después rompe a los clientes:

python
def registrar(app: FastAPI) -> None:
        @app.exception_handler(RequestValidationError)
    async def validacion(peticion: Request, exc: RequestValidationError):
                """Agrupa por campo, quita el valor enviado y devuelve 400."""
        errores: dict[str, list[str]] = {}
        for error in exc.errors():
                        campo = '.'.join(str(x) for x in error['loc'][1:]) or 'general'
            errores.setdefault(campo, []).append(error['msg'])
        return JSONResponse(
            status_code=status.HTTP_400_BAD_REQUEST,
            content={'detalle': 'Los datos enviados no son válidos.',
                     'errores': errores,
                     'documentacion': 'https://ejemplo.es/docs#errores'})

Y el resultado, con un cuerpo vacío:

Salida en pantalla
400
{"detalle": "Los datos enviados no son válidos.",
 "errores": {"titulo": ["Field required"],
             "isbn": ["Field required"],
             "precio": ["Field required"],
             "editorial_id": ["Field required"]},
 "documentacion": "https://ejemplo.es/docs#errores"}

Cuatro cosas arregladas respecto a lo que medía la lección 23: el código es 400 en vez de 422, los errores van agrupados por campo, el valor rechazado no aparece —y por eso una contraseña no acaba en los registros—, y hay un enlace a la documentación, que es lo que la lección 2 pedía de una API bien hecha.

Los fallos del curso, cerrados#

Esto es lo que merece la pena mirar. Los tres silencios que el curso midió:

Salida en pantalla
PATCH con errata     -> 400  {"titutlo": ["Extra inputs are not permitted"]}
ISBN repetido        -> 409  "Ya existe un libro con ese ISBN."
?tam=500             -> 400  {"tam": [...]}
?estado=inventado    -> 400  {"estado": [...]}
?cursor=xxxx         -> 400  "El cursor no es válido."

El primero es el de la lección 13, que allí contestaba 200 sin guardar nada. Lo cierra extra='forbid' de la lección 25, y el error dice el nombre del campo mal escrito.

El tercero y el cuarto son los de la lección 19, donde un parámetro con un valor imposible se ignoraba en silencio. Aquí las restricciones del tipo los rechazan.

Y el segundo es el de la lección 2: un IntegrityError sin tratar habría sido un 500, culpando al servidor de un error del cliente.

Los permisos, comprobados:

Salida en pantalla
GET  /api/libros/  sin token          -> 200   (la lectura es pública)
POST /api/libros/  sin token          -> 401
POST con papel de lector              -> 403
POST con cuenta desactivada           -> 403
POST con papel de editor              -> 201

La distinción entre 401 y 403 de la lección 18, y la lectura pública con escritura protegida, que es lo que casi todas las APIs necesitan.

La paginación por cursor#

La parte con más trabajo, porque es la que la lección 20 dejó a medias:

python
def _cursor(libro: Libro) -> str:
        bruto = f'{libro.creado.isoformat()}|{libro.id}'
    return base64.urlsafe_b64encode(bruto.encode()).decode().rstrip('=')


consulta = (select(Libro)
            .options(selectinload(Libro.editorial))
            .where(*filtros)
            .order_by(Libro.creado.desc(), Libro.id.desc()))
if cursor:
        creado, ident = _leer_cursor(cursor)
    consulta = consulta.where(
        (Libro.creado < creado)
        | ((Libro.creado == creado) & (Libro.id < ident)))

libros = list(await sesion.scalars(consulta.limit(tam + 1)))
hay_mas = len(libros) > tam
libros = libros[:tam]

Tres detalles que hacen que funcione:

El cursor lleva dos campos, la fecha y el identificador, porque varios libros pueden tener la misma fecha. Sin el identificador de desempate se saltan filas, y es el error clásico.

Se piden tam + 1 filas para saber si hay más sin hacer otra consulta.

Y la ordenación es la misma en los dos sitios, que es el aviso de estabilidad de la lección 19.

Funcionando:

Salida en pantalla
recorrer el catálogo entero:
  5 páginas, 45 libros, 45 distintos, total dice 45

insertando 5 libros a mitad del recorrido:
  recorridos 45, duplicados 0

Esa segunda medida es la prueba de la lección 20, donde la paginación por número de página devolvía elementos repetidos. Aquí no hay ninguno.

Las pruebas#

Doce, y son las que el curso fue señalando:

python
async def test_el_patch_con_errata_no_pasa_en_silencio(cliente, editora):
        """El fallo de la lección 13, ya cerrado."""
    lid = (await cliente.post('/api/libros/', json=LIBRO,
                              headers=editora)).json()['id']
    r = await cliente.patch(f'/api/libros/{lid}', json={'titutlo': 'X'},
                            headers=editora)
    assert r.status_code == 400
    assert 'titutlo' in r.json()['errores']


async def test_los_errores_no_devuelven_lo_enviado(cliente, editora):
        """El fallo de la lección 23, ya cerrado."""
    r = await cliente.post('/api/libros/',
                           json={**LIBRO, 'precio': 'no-es-un-precio'},
                           headers=editora)
    assert r.status_code == 400
    assert 'no-es-un-precio' not in r.text


async def test_el_cursor_recorre_todo_sin_repetir(cliente, editora):
        url, vistos = '/api/libros/?tam=10', []
    while url:
                d = (await cliente.get(url)).json()
        vistos += [l['id'] for l in d['resultados']]
        url = d['siguiente']
    assert len(vistos) == 25 == len(set(vistos))


async def test_cada_prueba_tiene_su_base(cliente):
        assert (await cliente.get('/api/libros/')).json()['total'] == 0
Salida en pantalla
12 passed in 2.04s

La segunda es la que más vale de todas, y por una razón de forma: comprueba lo que no está, buscando el texto rechazado en la respuesta entera. Ese tipo de prueba —la que verifica una ausencia— es la que detecta las fugas, y casi nadie la escribe.

Y la última sigue siendo la que sostiene a las demás.