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.
Ú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#
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 pruebasLa 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:
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:
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ó:
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:
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 -> 201La 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:
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:
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 0Esa 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:
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'] == 012 passed in 2.04sLa 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.