ATENCIÓN
El código de esta presentación funciona con Python 3.12
Versiones anteriores pueden requerir cambios en el código
Una arquitectura monolítica es un modelo de desarrollo de software donde todos los componentes de una aplicación están integrados en una sola unidad.
La interfaz de usuario, la lógica de negocio y el acceso a datos están dentro del mismo código base y se despliegan juntos.
Vamos a desarrollar dos casos prácticos para entender cómo implementar una arquitectura monolítica:
API: Application Programming Interface
Vamos a realizar la implementación de una API REST.
Recordad que una API REST debe seguir los principios RESTful:
Frameworks comunes para crear APIs REST en Python:
Vamos a utilizar FastAPI para implementar nuestra API de inferencia.
from fastapi import FastAPI
from transformers import pipeline
app = FastAPI()
@app.post("/translate")
def translate(prompt: str):
# Carga el modelo de traducción
pipe = pipeline("translation", model="Helsinki-NLP/opus-mt-es-en")
translated_text = pipe(prompt)[0]['translation_text']
return {"translated_text": translated_text}
prompt
se recibe en el Query String: http://dominio/translate
?prompt=texto
Demo
Petición POST con Query Params
from fastapi import FastAPI
from transformers import pipeline
app = FastAPI()
# ✅ Carga única del modelo al arrancar la aplicación
# ❌ Todavía no se gestiona la liberación de recursos
pipe = pipeline("translation", model="Helsinki-NLP/opus-mt-es-en")
# Añadimos este método para simplificar el endpoint
def translate_text(text: str) -> str:
if pipe is None:
raise RuntimeError("Translation service not initialized")
return pipe(text)[0]['translation_text']
@app.post("/translate")
def translate(prompt: str):
translated_text = translate_text(prompt)
return {"translated_text": translated_text}
lifespan
from contextlib import asynccontextmanager
from fastapi import FastAPI
from transformers import pipeline
pipe = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pipe # Declare global to modify the outer scope
# Initialize the translation pipeline
pipe = pipeline("translation", model="Helsinki-NLP/opus-mt-es-en")
yield
# Cleanup
del pipe
app = FastAPI(lifespan=lifespan) # Attach lifespan handler
yield
pausa la ejecución de la función. Cuando se vuelve a llamar, la función continúa desde donde se quedó.lifespan
al arrancar la aplicación y la vuelve a llamar al cerrarla.Inversión de control
Recuerda, muchas librerías y frameworks utilizan el patrón de Inversión de Control para gestionar el ciclo de vida de los recursos.
async
permite que una función se ejecute de forma asíncrona, lo que es útil para tareas que pueden bloquear la ejecución principal.
Asynchronous Server Gateway Interface (ASGI)
FastAPI soporta funciones asíncronas para gestionar tareas que requieren tiempo.
Pydantic
permite validar los datos de forma sencilla.Demo
Petición POST con JSON
class TranslationResponse(BaseModel):
translated_text: str
@app.post("/translate", response_model=TranslationResponse)
async def translate(request: TranslationRequest) -> dict[str, str]:
prompt = request.text
translated_text = translate_text(prompt)
response = TranslationResponse(translated_text=translated_text)
return response
# return {"translation_text": translated_text} # Alternativa
list
, dict
, tuple
y set
como tipos genéricos.typing
.import logging
# Create logs folder if it doesn't exist
os.makedirs('logs', exist_ok=True)
logging.basicConfig(level=logging.INFO, filename='../logs/translate.log', filemode='a', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@app.post("/translate")
async def translate(request: TranslationRequest) -> dict[str, str]:
prompt = request.text
logger.info(f"Translating: {prompt}")
translated_text = translate_text(request.text)
response = TranslationResponse(translated_text=translated_text)
return response
Deberíamos configurar el nivel de log según el entorno (desarrollo: INFO/DEBUG, producción: WARNING/ERROR).
FastAPI genera automáticamente la documentación de la API con OpenAPI.
Se puede completar con descripciones, ejemplos y validaciones.
http://localhost:8000/docs
para ver la documentación interactiva.http://localhost:8000/redoc
para ver la documentación en formato ReDoc.Esta documentación es muy útil para los desarrolladores que consumen la API.
@app.post(
"/translate",
response_model=TranslationResponse,
summary="Translate text from Spanish to English",
description="""
Translate text using a pre-trained model from Hugging Face:
https://huggingface.co/Helsinki-NLP/opus-mt-en-es
"""
)
async def translate(request: TranslationRequest) -> dict[str, str]:
translated_text = translate_text(request.text)
response = TranslationResponse(translated_text=translated_text)
return response
db.py
: base de datos (FAKE) con usuarios y claves API.
users = [
{
"name": "Alice",
"api_key": "5f0c7127-3be9-4488-b801-c7b6415b45e9"
},
{
"name": "Bob",
"api_key": "e54d4431-5dab-474e-b71a-0db1fcb9e659"
}
]
# For demonstration purposes only, YOU MUST USE A DATABASE INSTEAD
def get_user_from_api_key(api_key: str):
for user in users:
if user["api_key"] == api_key:
return user
return None
auth.py
: middleware para autenticar usuarios.
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
from db import get_user_from_api_key
api_key_header = APIKeyHeader(name="X-API-Key")
def get_user(api_key_header: str = Security(api_key_header)):
user = get_user_from_api_key(api_key_header)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid API key"
)
return user
from fastapi import Depends
from auth import get_user
@app.post("/secure/translate", response_model=TranslationResponse)
async def translate(
request: TranslationRequest,
user: dict = Depends(get_user)
) -> dict[str, str]:
logger.info(f"Translation request by user {user['name']}")
translated_text = translate_text(request.text)
response = TranslationResponse(translated_text=translated_text)
return response
FastAPI permite definir dependencias para inyectar datos en los endpoints.
import os
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from pydantic import BaseModel
from transformers import pipeline
from auth import get_user
# Create logs folder if it doesn't exist
os.makedirs('logs', exist_ok=True)
logging.basicConfig(level=logging.INFO, filename='../logs/translate.log', filemode='a', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Global variable to store the translation pipeline
pipe = None
# Initialize the translation pipeline at startup
@asynccontextmanager
async def lifespan(app: FastAPI):
global pipe # Declare global to modify the outer scope
logger.info("Initializing translation pipeline")
pipe = pipeline("translation", model="Helsinki-NLP/opus-mt-es-en")
yield
logger.info("Cleaning up translation pipeline")
del pipe
# Function to translate text using the pipeline
def translate_text(text: str) -> str:
if pipe is None:
raise RuntimeError("Translation service not initialized")
return pipe(text)[0]['translation_text']
# Create the FastAPI app
app = FastAPI(lifespan=lifespan)
# Define the request and response models
class TranslationRequest(BaseModel):
text: str
class TranslationResponse(BaseModel):
translated_text: str
# Define the translation endpoint
@app.post(
"/translate",
response_model=TranslationResponse,
summary="Translate text from Spanish to English",
description="Translate text using a pre-trained model."
)
async def translate(request: TranslationRequest) -> dict[str, str]:
translated_text = translate_text(request.text)
response = TranslationResponse(translated_text=translated_text)
return response
# Define the translation endpoint
@app.post(
"/secure/translate",
response_model=TranslationResponse,
summary="Translate text from Spanish to English",
description="Translate text using a pre-trained model."
)
async def secure_translate(
request: TranslationRequest,
user: dict = Depends(get_user)
) -> dict[str, str]:
logger.info(f"Translation request by user {user['name']}")
translated_text = translate_text(request.text)
response = TranslationResponse(translated_text=translated_text)
return response
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
from db import get_user_from_api_key
api_key_header = APIKeyHeader(name="X-API-Key")
def get_user(api_key_header: str = Security(api_key_header)):
user = get_user_from_api_key(api_key_header)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid API key"
)
return user
users = [
{
"name": "Alice",
"email": "alice@example.com",
"api_key": "5f0c7127-3be9-4488-b801-c7b6415b45e9"
},
{
"name": "Bob",
"email": "bob@example.com",
"api_key": "e54d4431-5dab-474e-b71a-0db1fcb9e659"
}
]
# For demonstration purposes only, YOU MUST USE A DATABASE INSTEAD
def get_user_from_api_key(api_key: str):
for user in users:
if user["api_key"] == api_key:
return user
return None
FastAPI trata internamente de forma diferente las funciones asíncronas y síncronas.
async def
se ejecuta en el bucle de eventos de FastAPI, usando asyncio
.def
se ejecuta en un hilo separado.Si llamamos a una función síncrona desde una función asíncrona, bloqueamos el bucle de eventos.
from fastapi import FastAPI
import time
app = FastAPI()
# This endpoint blocks the event loop
# It should be sync -> def block():
@app.get("/block")
async def block():
time.sleep(10)
return {"msg": "Sorry, I blocked the event loop"}
@app.get("/blocked")
async def blocked():
return {"msg": "I had to wait for the other endpoint to finish"}
Cuando llamamos a otras funciones asíncronas se ejecutan en paralelo.
Si necesitamos su valor de retorno, usamos await
.
Una forma común de estructurar el código es siguiendo el patrón de Arquitectura en Capas.
La arquitectura en capas organiza un sistema en módulos independientes, facilitando el mantenimiento y escalabilidad.
Hay muchas formas de implementar una arquitectura en capas, pero esta es sencilla y efectiva.
Separación de responsabilidades: Cada capa tiene una función clara y se comunican entre sí de forma jerárquica.
Cada capa debe depender sólo de las capas inferiores
El código se organiza en distintos paquetes lógicos.
Ejemplo:
📂 src/
│── 📄 main.py # Inicia el servidor FastAPI
│── 📂 db/
│ │── 📄 db.py # Configuración de la base de datos
│ └── 📄 models.py # Modelos de datos (SQLModel)
│── 📂 services/ # Lógica de negocio
│── 📂 controllers/ # Endpoints de la API
│── 📂 middleware/ # Autenticación, logging, etc.
└── 📂 util/ # Funciones auxiliares
Los sistemas ORM (Object-Relational Mapping) facilitan el acceso a bases de datos desde Python.
Permiten definir modelos de datos y operar con ellos de forma sencilla, sin necesidad de escribir SQL.
from sqlmodel import create_engine, Session
# Cadena de conexión a la base de datos
DATABASE_URL = "mysql+pymysql://user:password@localhost/database"
# Crear el motor de conexión
engine = create_engine(DATABASE_URL, echo=True)
# Función para obtener la sesión de base de datos
def get_session():
session = Session(engine)
try:
return session # Devolvemos la sesión directamente
except Exception as e:
session.rollback() # Hacemos rollback si hay un error
raise e
finally:
session.close() # Nos aseguramos de cerrar la sesión cuando se salga de la función
SQLModel
.SQLModel se encarga de mapear los atributos de la clase con las columnas de la tabla, usando el tipo de datos más adecuado en cada motor de base de datos.
class User(SQLModel, table=True):
id: int = Field(primary_key=True)
name: str = Field(index=True)
email: str
posts: list["Post"] = Relationship(back_populates="user")
class Post(SQLModel, table=True):
id: int = Field(primary_key=True)
title: str
content: str
user_id: int = Field(foreign_key="user.id")
user: User = Relationship(back_populates="posts")
class User(SQLModel, table=True):
id: int = Field(primary_key=True)
name: str = Field(index=True)
email: str
posts: list["Post"] = Relationship(back_populates="user")
class Post(SQLModel, table=True):
id: int = Field(primary_key=True)
title: str
content: str
user_id: int = Field(foreign_key="user.id")
user: User = Relationship(back_populates="posts")
user_id
: clave ajena a la tabla user
.user
: almacenará el objeto User
relacionado.posts
: lista de objetos Post
relacionados.Distintas formas de relacionar instancias:
with get_session() as db:
user = User(name="Alice", email="alice@example.com")
db.add(user)
db.commit() # Insertamos el usuario en la base de datos para obtener el id
post1 = Post(user_id=user.id, title="First post", content="This is the first post")
post2 = Post(user=user, title="Second post", content="This is the second post")
post3 = Post(title="Third post", content="This is the third post")
post3.user = user
db.add_all([post1, post2, post3])
post4 = Post(title="Fourth post", content="This is the fourth post")
user.posts.append(post4)
DATABASE_URL.add(user)
db.commit()
Las relaciones permiten obtener los objetos relacionados de forma sencilla.
En las primeras fases de desarrollo podemos necesitar recrear la base de datos para probar cambios en los modelos.
@asynccontextmanager
async def lifespan(app: FastAPI):
logging.info("Recreating database tables")
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
logging.info("Seeding users")
seed_users()
logging.info("Application started")
yield
logging.info("Application shutdown")
app = FastAPI(lifespan=lifespan)
@asynccontextmanager
async def lifespan(app: FastAPI):
logging.info("Recreating database tables")
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
logging.info("Seeding users")
seed_users()
logging.info("Application started")
yield
logging.info("Application shutdown")
app = FastAPI(lifespan=lifespan)
Esta capa contiene la lógica de negocio y se encarga de coordinar las operaciones entre la capa de presentación y la capa de datos.
La lógica de negocio es el conjunto de reglas y procesos que definen cómo funciona una aplicación.
Ejemplo: en una biblioteca, no se puede prestar un libro a un usuario en determinados casos:
from sqlmodel import select, Session
from db import User
class UserService:
@staticmethod
def get_all(db: Session):
return db.exec(select(User)).all()
@staticmethod
def get_by_id(user_id: int, db: Session):
user = db.exec(select(User).where(User.id == user_id)).first()
if not user:
raise ValueError("User not found")
return user
class UserService:
@staticmethod
def queries(db: Session):
# all() devuelver una lista
users = db.exec(select(User)).all()
users = db.exec(select(User).where(
User.email.like(f"%example.com%"))
).all()
# first() devuelve el primer elemento
user = db.exec(select(User)).first()
user = db.exec(select(User).where(User.id == 1)).first()
Cualquier operación que modifique la base de datos debe confirmarse con commit()
.
class UserService:
@staticmethod
def inserts(db: Session):
user = User(name="Mary", email="mary@example.com")
db.add(user) # Inserta un usuario
db.commit() # Confirma los cambios
users = [
User(name="Alice", email="alice@example.com"),
User(name="Bob", email="bob@example.com", phone="123456789"),
User(name="Charlie", email="charlie@example.com", country="US")
]
db.add_all(users) # Inserta varios usuarios
db.commit() # Confirma los cambios
Cualquier operación que modifique la base de datos debe confirmarse con commit()
.
class UserService:
@staticmethod
def update(db: Session):
user = db.exec(select(User).where(User.name == "Mary")).first()
user.name = "Maria" # Cambia el nombre del usuario
db.add(user) # Si tiene un id, actualiza el usuario
db.commit() # Confirma los cambios
@staticmethod
def delete(db: Session):
user = db.exec(select(User).where(User.name == "Maria")).first()
db.delete(user) # Elimina un usuario
db.commit() # Confirma los cambios
Método HTTP | Uso |
---|---|
GET | Obtener datos (GET /users/{id} ) |
POST | Crear un nuevo recurso (POST /users/ ) |
PUT | Actualizar un recurso (PUT /users/{id} ) |
DELETE | Eliminar un recurso (DELETE /users/{id} ) |
Código | Significado | Uso en API REST |
---|---|---|
200 OK | Éxito | Respuesta con éxito a una solicitud GET o PUT. |
201 Created | Recurso creado | Se usa en respuestas de POST cuando se crea un nuevo recurso. |
204 No Content | Sin contenido | La solicitud terminó con éxito pero no hay datos que devolver (ej. DELETE). |
Código | Significado | Uso en API REST |
---|---|---|
400 Bad Request | Error del cliente | Datos enviados incorrectos o inválidos. |
401 Unauthorized | No autenticado | El usuario no ha iniciado sesión. |
403 Forbidden | Acceso denegado | El usuario no tiene permisos para acceder al recurso. |
404 Not Found | No encontrado | El recurso no existe. |
409 Conflict | Conflicto | Error debido a datos duplicados o reglas de negocio. |
422 Unprocessable Content | Entidad no procesable | Error de validación de datos (usado por Pydantic). |
Código | Significado | Uso en API REST |
---|---|---|
500 Internal Server Error | Error del servidor | Fallo inesperado en el servidor. |
Estos errores surgen si hay errores (excepciones) en tiempo de ejecución.
Deben ser capturados y gestionados para evitar que la aplicación falle.
Los endpoints no deben exponer más información de la necesaria.
user
completo estamos exponiendo más información de la necesaria (incluyendo la contraseña).Al devolver modelos Pydantic se filtrarán automáticamente los campos a devolver:
Los modelos Pydantic también nos permiten devolver información agregada de varios modelos:
Ejemplo de validación manual:
@app.post("/users/{name}")
async def get_user_by_name(name: str):
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
if len(name) > 1000:
raise HTTPException(status_code=400, detail="Name is too long")
if any(bad_word in name.lower() for bad_word in [
"<script>",
"drop table",
"select *",
"delete from",
"update users",
"password",
"' or 1=1",
"http://",
"https://"
]):
raise HTTPException(status_code=400, detail="Name contains invalid content")
# Access the database, call the model, etc.
SQLModel
y Pydantic
permiten realizar la validación automáticamente.
Estas librerías son muy útiles para evitar errores y proteger la aplicación.
Para devolver errores lanzamos HTTPException
.
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/users/{id}", response_model=UserResponseModel)
def get_user(id: int, db : Session = Depends(get_session)) -> UserResponseModel:
try:
user = user_service.get_by_id(id, db)
return user
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
controllers/user_controller.py
controllers/__init__.py
Métodos comunes de autenticación:
Una vez el usuario se ha autenticado, la aplicación puede comprobar si tiene permisos para acceder a un recurso.
JSON Web Token (JWT)
Solicitud del token (autenticación)
Solicitud del token (autorización)
El cliente debe guardar el token y enviarlo en cada petición.
Para aumentar la seguridad se pueden añadir mecánismos de refresco y revocación de tokens.
Ingeniería del Software para Inteligencia Artificial