100 Días de Código en Python — Día 66: Tu propia API REST con Flask
Nivel: Grado décimo
Área: Tecnología e Informática
Modalidad: Tutorial práctico
Fuente: Curso "100 Days of Code: The Complete Python Pro Bootcamp" (Angela Yu, App Brewery). Contenido del Día 66 (nivel avanzado).
22 min de lectura
19 secciones
Este tutorial adapta pedagógicamente el Día 66. Las secciones 📝 Contenido original del curso y 📝 Ejercicio original del curso provienen de las lecciones 464, 465 y 467 a 475 (la lección 466, con los archivos de inicio, no está entre los archivos suministrados; el proyecto parte de una base de datos cafes.db de cafeterías). Las soluciones oficiales están en los gists enlazados y el proyecto final (day-66-cafe-api-end.zip) en los recursos. Los retos 🟢🟡🟠🔴, las secciones sobre códigos de estado y seguridad de la clave, el proyecto extra y la etapa 8 del proyecto integrador son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.
Nota sobre las verificaciones: el 27 de septiembre de 2026, con Python 3.14, Flask 3.1.3 y Flask-SQLAlchemy 3.1.1, se construyó una API de cafeterías equivalente a la del curso (con datos colombianos de prueba) y se probaron todas sus rutas con el cliente de pruebas de Flask. Se comprobó: que jsonifyordena las claves alfabéticamente y escapa las tildes (Usaquén), que la búsqueda por ubicación distingue mayúsculas, que bool("0") es True (un error típico al leer casillas de un formulario), que un nombre repetido provoca un error 500, los códigos 403, 404 y 405, y que httpstatuses.com, enlazado por el curso, ya no muestra la lista de códigos (redirige a otro sitio).
Desde el Día 33 has usado APIs públicas (la ubicación de la ISS, preguntas de trivia, Twilio). Muchas empresas reúnen datos valiosos (precios de bitcoin, reseñas de restaurantes) y cobran por el acceso a su API: de 9 a 99 dólares al mes, o por cada llamada. ¿Y si tú tienes datos que otros quieren? Por ejemplo, las cafeterías de una ciudad aptas para trabajar con el portátil. Podrías crear una API y cobrar por el acceso. Hoy construyes una API REST completa con Flask.
Prerequisitos: APIs desde el lado del cliente con requests (Días 33 a 37), GET y POST en Flask (Día 60), CRUD con SQLAlchemy (Días 63 y 64), diccionarios y JSON (Días 9 y 30).
REST no es "descansar en el sofá viendo Netflix": significa REpresentational State Transfer (transferencia de estado representacional). Para entenderlo, volvemos a la arquitectura cliente-servidor con la analogía del restaurante: el cliente pide una pizza; el mesero revisa si el restaurante puede cumplir. Si pide algo que no está en el menú (una salchicha), recibe un 404: el recurso no existe.
La solicitud se hace en un "idioma": HTTP. Hay otros, como FTP (transferencia de archivos); si hablas el idioma equivocado, no te entienden.
HTTPS es HTTP seguro: la solicitud va cifrada, como si tú y el mesero hablaran klingon; aunque alguien escuche, no entiende. Indispensable al enviar datos de una tarjeta.
Para responder, el servidor puede calcular algo o consultar la base de datos, o ambas.
Una API es como el menú: la lista de cosas que el servidor sabe responder.
REST es un estilo arquitectónico, como el barroco, el gótico o el neoclásico en los edificios. Hay otros estilos para APIs: SOAP (el dominante antes de REST), GraphQL, Falcor… pero REST es "el estándar de oro". Lo propuso Roy Fielding en su tesis doctoral en la Universidad de California: si todas las APIs siguieran las mismas reglas, sería fácil usarlas todas (como si en todos los edificios los baños estuvieran en el mismo lugar).
Las dos reglas más importantes:
Usar los verbos HTTP, que se corresponden con CRUD:
Verbo HTTP
CRUD
Ejemplo
GET
Read
Ver artículos
POST
Create
Crear un artículo
PUT
Update (reemplazar todo)
Reemplazar un artículo
PATCH
Update (cambiar una parte) — agregado a HTTP en 2010
Cambiar solo el título
DELETE
Delete
Borrar
Usar un patrón de rutas específico (como las rutas de un safari hacia los elefantes o las jirafas). Con el ejemplo de una API tipo Wikipedia:
En REST, la ruta nombra un recurso (un sustantivo: /cafes, /cafes/22) y el verbo dice qué hacer con él. Por eso una ruta como /update-price o /report-closed (verbos en la URL), que usa el curso, es menos REST que PATCH /cafes/22. Lo verás en los retos.
"DELETE /articles borra todos": en una API real, eso casi nunca se expone, y si existe, se protege mucho.
04
2. GET: una cafetería al azar y serialización a JSON#
Un caso de uso probable: un desarrollador quiere mostrar a su usuario una cafetería al azar.
Crea la ruta /random que acepte GET.
Al recibir la solicitud, obtén una cafetería al azar de la base de datos (sin devolver nada todavía).
Hasta ahora devolvías plantillas con render_template(), pero una API devuelve JSON, como la API de la ISS. Convertir el objeto de SQLAlchemy a JSON se llama serialización. Flask trae jsonify(), pero tú defines la estructura. Con su documentación, haz que /random funcione.
Dos formas de serializar:
Escribir la estructura a mano: máxima flexibilidad; puedes omitir campos (como id) o agrupar los booleanos en una subsección amenities.
Convertir el objeto a diccionario y pasárselo a jsonify(), para no escribir todos los campos en cada ruta.
import random
from flask import jsonify
classCafe(db.Model):...defto_dict(self):# Un diccionario {nombre_de_columna: valor} con todas las columnas de la tablareturn{column.name:getattr(self, column.name)for column in self.__table__.columns}@app.route("/random")defget_random_cafe():
cafes = db.session.execute(db.select(Cafe)).scalars().all()
random_cafe = random.choice(cafes)return jsonify(cafe=random_cafe.to_dict())
self.__table__.columns es la lista de columnas del modelo; getattr(objeto, "name") equivale a objeto.name.
jsonify(cafe=...) crea {"cafe": {...}} y pone la cabecera Content-Type: application/json (verificado).
Las claves salen en orden alfabético (Flask ordena por defecto; se puede cambiar con app.json.sort_keys = False).
None de Python se vuelve null, y False, false.
Las tildes salen escapadas (é): es JSON válido y cualquier cliente lo convierte a "é". Para verlas tal cual: app.json.ensure_ascii = False.
💡 Desde Flask 1.1, una ruta también puede devolver un diccionario directamente (return {"cafe": ...}) y Flask lo convierte a JSON. jsonify sigue siendo útil para listas y para ser explícito.
🛠️ Nota técnica: elegir al azar cargando todas las cafeterías con .all() está bien para decenas de registros. Con miles, conviene pedirle el azar a la base de datos: db.select(Cafe).order_by(db.func.random()).limit(1).
05
3. GET: todas las cafeterías y búsqueda por ubicación#
(Fuentes: 468-http-get-all-the-cafes.md y 469-http-get-find-a-cafe.md)
Todas (/all). Un sitio que liste todas las cafeterías (como laptopfriendly.co/london) necesita pedirlas todas. Crea /all que devuelva todas las cafeterías como JSON. Pista: .execute() con .select() y .scalars() para obtener una lista.
Búsqueda (/search). Crea /search para buscar cafeterías en una zona (el campo location de cafes.db). La ubicación llega como parámetro en la URL después de ?: /search?loc=Peckham. Si la zona no existe, devuelve un error en JSON. Pista: filtra con .where().
@app.route("/all")defget_all_cafes():
cafes = db.session.execute(db.select(Cafe).order_by(Cafe.name)).scalars().all()return jsonify(cafes=[cafe.to_dict()for cafe in cafes])@app.route("/search")defsearch_cafes():
location = request.args.get("loc")
cafes = db.session.execute(db.select(Cafe).where(Cafe.location == location)).scalars().all()if cafes:return jsonify(cafes=[cafe.to_dict()for cafe in cafes])return jsonify(error={"Not Found":"Sorry, we don't have a cafe at that location."}),404
request.args lee los parámetros de consulta (Día 60).
Devolver una tupla(json, 404) envía el código de estado correcto.
Comportamiento verificado:
Solicitud
Código
/search?loc=Chapinero
200 con la lista
/search?loc=chapinero (minúsculas)
404: la comparación distingue mayúsculas
/search sin parámetro
404 (loc es None)
Para buscar sin distinguir mayúsculas: where(db.func.lower(Cafe.location) == location.lower()). Y si falta el parámetro, lo más claro es responder 400 ("falta el parámetro loc").
🟠 Reto 3 — Desafío: pagina /all: /all?page=2&per_page=5, con los metadatos page, total y pages en la respuesta.
🔴 Reto 4 — Pensamiento computacional: rediseña las rutas del curso al estilo REST "puro" (GET /cafes, GET /cafes/<id>, GET /cafes?loc=..., POST /cafes, PATCH /cafes/<id>, DELETE /cafes/<id>) y compara en una tabla con las del curso.
Probar una API escribiendo parámetros en la barra del navegador es cansado y propenso a errores. Postman (gratuito) permite escribir los parámetros como pares clave-valor (arma la URL solo) y generar documentación automáticamente. Crea una colección llamada "Cafe & Wifi" y guarda en ella las 3 rutas; es importante para generar la documentación después. Prueba pedir una cafetería al azar desde Postman.
El cliente de pruebas de Flask (app.test_client())
No necesita ni arrancar el servidor; ideal para assert
curl en la terminal
Viene en Windows 10+, Mac y Linux
Extensiones de VS Code (por ejemplo, Thunder Client) o Bruno
Parecidas a Postman, sin salir del editor
Python
12345
import requests
BASE ="http://127.0.0.1:5000"print(requests.get(f"{BASE}/random").json())print(requests.get(f"{BASE}/search", params={"loc":"Chapinero"}).status_code)
¿Y si los usuarios pudieran sugerir cafeterías (como laptopfriendly.co/suggest)? El POST vendría de un formulario, pero se puede simular con Postman: los pares clave-valor de la pestaña Body equivalen a los <input name="..."> de un formulario. "Es tan bueno haciendo POST que algunos lo llaman POSTman". Crea la ruta sin WTForms.
@app.route("/add", methods=["POST"])defpost_new_cafe():
new_cafe = Cafe(
name=request.form.get("name"),
map_url=request.form.get("map_url"),
location=request.form.get("loc"),
has_wifi=a_booleano(request.form.get("wifi")),...)
db.session.add(new_cafe)
db.session.commit()return jsonify(response={"success":"Successfully added the new cafe."}),201
🛠️ Nota técnica (verificado): los datos de un formulario llegan siempre como texto. bool("0") es True (cualquier texto no vacío es verdadero), así que una cafetería enviada con wifi=0 queda registrada con wifi. Convierte explícitamente:
🛠️ Nota técnica (verificado): si se envía un nombre que ya existe (columna unique), el commit() lanza IntegrityError y la API responde 500. Atrapa la excepción, haz db.session.rollback() y responde 409 Conflict o 400 con un mensaje claro. Lo mismo si falta un campo obligatorio.
¿Formulario o JSON? Muchas APIs reciben los datos como JSON en el cuerpo (Content-Type: application/json). En Flask se leen con request.get_json(). Con requests: requests.post(url, json={...}) (JSON) o requests.post(url, data={...}) (formulario).
La analogía de la bicicleta. La profesora compró una bicicleta que llegó con la rueda delantera dañada. Amazon ofreció dos soluciones: enviar una bicicleta completa nueva (PUT: reemplazar todo el registro) o enviar solo la rueda (PATCH: enviar solo lo que cambia, "y salvar al planeta de emisiones").
El ejercicio. Los precios de las cafeterías cambian. Si el usuario conoce el id (lo obtiene de /all), puede actualizar solo coffee_price: un PATCH es lo más eficiente.
Crea una ruta PATCH /update-price/<cafe_id>; por ejemplo, /update-price/22.
Pista 1: usa .get() con try/except para obtener la cafetería por id; si hay AttributeError, devuelve un error en JSON.
Pista 2: el nuevo precio llega como parámetro de la solicitud.
Si el id no existe, informa al usuario.
Nota del curso: aunque el recurso no se encuentre, la respuesta devuelve 200 ("todo bien"); debería ser 404. El curso muestra cómo devolver un código HTTP con la respuesta.
@app.route("/update-price/<int:cafe_id>", methods=["PATCH"])defpatch_new_price(cafe_id):
cafe = db.session.get(Cafe, cafe_id)if cafe isNone:return jsonify(error={"Not Found":"Sorry a cafe with that id was not found in the database."}),404
cafe.coffee_price = request.args.get("new_price")
db.session.commit()return jsonify(response={"success":"Successfully updated the price."}),200
db.session.get(Cafe, id) devuelve None si no existe. Comprobar if cafe is None es más claro que esperar un AttributeError.
Visitar /update-price/1 con GET (en el navegador) da 405 Method Not Allowed: la ruta solo acepta PATCH (verificado).
🛠️ Nota técnica: el nuevo precio viaja en la URL (?new_price=...). Funciona, pero lo habitual en REST es enviarlo en el cuerpo de la solicitud (formulario o JSON), como en el POST.
Cuando una cafetería cierra, hay que borrarla. Pero no cualquiera debería poder borrar: "podríamos terminar con alguien que borra todo". Medida de seguridad: exigir una api-key. Si es "TopSecretAPIKey", se permite; si no, se responde 403 (no autorizado). La lista de códigos está en httpstatuses.com.
Crea la ruta DELETE /report-closed/<cafe_id>, con tres casos: clave correcta (borrado), clave incorrecta (403) e id inexistente (404).
import os
API_KEY = os.environ.get("CAFE_API_KEY","TopSecretAPIKey")# la del curso solo para practicar@app.route("/report-closed/<int:cafe_id>", methods=["DELETE"])defdelete_cafe(cafe_id):if request.args.get("api-key")!= API_KEY:return jsonify(error="Sorry, that's not allowed. Make sure you have the correct api_key."),403
cafe = db.get_or_404(Cafe, cafe_id)
db.session.delete(cafe)
db.session.commit()return jsonify(response={"success":"Successfully deleted the cafe from the database."}),200
Resultados verificados: sin clave → 403; con clave → 200; borrar el mismo id otra vez → 404.
🛠️ Nota técnica (seguridad): la clave del curso viaja en la URL (?api-key=...), y las URL quedan guardadas en el historial, en los registros del servidor y en las herramientas intermedias. En APIs reales la clave se envía en una cabecera (por ejemplo, X-API-Key o Authorization), siempre sobre HTTPS, y no se escribe en el código. Además, una sola clave compartida por todos no permite saber quién borró; lo resolverás con usuarios el Día 68.
🛠️ Nota técnica: estrictamente, 401 Unauthorized significa "no te identificaste" (falta la clave) y 403 Forbidden, "sé quién eres, pero no tienes permiso". Muchas APIs usan 401 para una clave ausente o inválida. El curso usa 403; ambas son defendibles si se documentan.
(Sección creada para esta adaptación, a partir de las lecciones 469, 473 y 474.)
🛠️ Nota técnica (verificado):httpstatuses.com, enlazado por el curso, ahora redirige a un glosario de otra empresa. Una referencia confiable es la documentación de MDN ("HTTP response status codes").
Código
Nombre
Cuándo usarlo en esta API
200
OK
Lectura, actualización o borrado exitosos
201
Created
Se creó una cafetería (POST)
400
Bad Request
Faltan datos o tienen mal formato
401
Unauthorized
Falta la clave o no es válida
403
Forbidden
La clave es válida, pero no tiene permiso
404
Not Found
El recurso (id o zona) no existe
405
Method Not Allowed
Verbo equivocado para esa ruta (Flask lo hace solo)
409
Conflict
Ya existe (nombre repetido)
500
Internal Server Error
Un error tuyo sin manejar: nunca debería verlo un cliente
Las familias: 2xx éxito, 4xx error del cliente, 5xx error del servidor.
Si quieres que otros usen tu API, tienes que documentarla: nadie ve tu código, así que hay que explicar las rutas, los parámetros obligatorios, etc. Si hiciste todas las solicitudes en Postman con nombre y descripción, Postman genera la documentación:
Comprueba que todas las solicitudes funcionan.
Guárdalas en la misma colección ("Cafe & Wifi").
En los tres puntos de la colección, Publish Docs.
Publica (ejemplo del curso: documenter.getpostman.com/view/2568017/TVRhd9qR).
Agrega en index.html un enlace a la documentación.
📖 Qué debe tener una buena documentación (adaptación)#
Si no usas Postman, un API.md en el proyecto sirve igual. Por cada ruta:
Markdown
12345678910
### GET /search
Busca cafeterías por zona.
| Parámetro | Dónde | Obligatorio | Ejemplo ||---|---|---|---|| loc | URL (query) | Sí | Chapinero |
Respuestas: 200 con `{"cafes": [...]}` · 400 si falta `loc` · 404 si no hay cafeterías en esa zona.
Ejemplo: `curl "http://127.0.0.1:5000/search?loc=Chapinero"`
Tienes una base de datos valiosa (cafeterías aptas para trabajar) y quieres que otras aplicaciones la usen, sin darles acceso directo a tu base de datos.
Comprender la base de datos (ábrela en DB Browser: ¿qué columnas tiene?). 2. Entradas: parámetros y cuerpos. 3. Procesos: CRUD. 4. Salidas: JSON y códigos. 5. Dividir: una ruta a la vez, probándola antes de seguir. 6. Diseñar la tabla de pruebas. 7. Implementar. 8. Probar. 9. Corregir. 10. Documentar.
Versión 3:/add con prueba en Postman o con requests.
Versión 4: PATCH y DELETE con sus errores.
Versión final: pruebas automáticas y documentación.
Pruebas (automáticas, con el cliente de pruebas de Flask)#
Python
123456789
from main import app
cliente = app.test_client()assert cliente.get("/random").status_code ==200assert cliente.get("/search?loc=ZonaQueNoExiste").status_code ==404assert cliente.patch("/update-price/99999?new_price=1").status_code ==404assert cliente.delete("/report-closed/1").status_code ==403# sin claveassert cliente.get("/update-price/1").status_code ==405# verbo equivocadoprint("Todas las pruebas pasaron")
(Usa una copia de la base de datos para las pruebas que borran o modifican.)
Encuentra cinco problemas: la ruta no declara methods=["DELETE"]; falta int:; si el id no existe, delete(None) falla (500); falta db.session.commit(); la respuesta de error sale con 200 en lugar de 403.
La aplicación del colegio (y un bot del grupo del curso) quieren mostrar el menú del día. En el Día 57 hiciste páginas con el menú; ahora el restaurante necesita una API para que otros programas lo consulten.
Modelo Almuerzo (fecha, plato, bebida, postre, alérgenos como texto separado por comas, calorías aproximadas).
Rutas: GET /menu/hoy, GET /menu?fecha=AAAA-MM-DD, GET /menu/semana, GET /menu/sin/<alergeno>, POST /menu (con clave, para el restaurante), PATCH /menu/<id> (con clave) y DELETE /menu/<id> (con clave).
La clave se envía en la cabecera X-API-Key y se lee de una variable de entorno.
Fechas inválidas → 400 con un mensaje que explique el formato.
API.md con todas las rutas, parámetros, respuestas y ejemplos con curl.
Un cliente en Python que imprima el menú de hoy de forma bonita.
Ciclo de trabajo: formula el problema → inténtalo tú → consulta a la IA → lee y entiende → prueba → compara con la documentación oficial → modifica → explica con tus palabras.
Actividad 1 — Códigos de estado. Pide a una IA una ruta Flask para borrar un registro. ¿Devuelve 404 si no existe? ¿Usa DELETE o GET? Pruébala con el cliente de pruebas y compara con la tabla de la sección 8 y con MDN.
Actividad 2 — El booleano traicionero. Pregunta a una IA cómo leer una casilla de verificación de un formulario en Flask. ¿Usa bool(request.form.get(...))? Si sí, muéstrale el caso "0".
Actividad 3 — Documentar. Da a una IA tu código y pídele que genere API.md. Revisa cada ruta: ¿los parámetros, códigos y ejemplos coinciden con lo que tu código hace de verdad? Corrige lo que inventó.
17
15. 🛠️ Correcciones técnicas al material original#
Archivo
Afirmación o comportamiento del curso
Observación
Tratamiento
466
Lección de archivos de inicio
No está entre los archivos suministrados
Se indica en la cabecera
473
Pista: usar .get() y atrapar AttributeError
Con SQLAlchemy 2, db.session.get() devuelve None; es más claro comprobar None y responder 404
Sección 6
473
Precio nuevo como parámetro en la URL
Lo habitual es enviarlo en el cuerpo
Nota técnica de la sección 6
474
Clave "TopSecretAPIKey" en la URL y en el código
Debe ir en una cabecera, sobre HTTPS, y en una variable de entorno
Nota técnica de la sección 7
474
403 para clave incorrecta
401 también es válido; se explican ambos
Nota técnica de la sección 7
474
Enlace a httpstatuses.com
Ya no muestra los códigos (verificado); se recomienda MDN
Sección 8
471
Crear la cafetería con los datos del formulario
bool("0") es True y un nombre repetido da error 500 (verificado)
Notas técnicas de la sección 5
467
jsonify con la estructura del objeto
Las claves salen ordenadas alfabéticamente y las tildes escapadas (verificado)
Sección 2
465
"DELETE /articles borra todos los artículos"
Es el patrón teórico; en la práctica casi nunca se expone
La API de Cafés y Wifi con las 7 rutas y el archivo de pruebas automáticas pasando.
La documentación publicada (Postman) o API.md.
Un cliente en Python que consuma tu propia API.
La API del menú del restaurante escolar (proyecto extra).
La etapa 8 de la Plataforma del Club.
Cuaderno de errores: 405 por verbo equivocado, respuestas de error con 200, bool("0"), nombre repetido con error 500, delete(None), commit() olvidado, búsqueda que distingue mayúsculas, clave de API en la URL, render_template en lugar de jsonify.