Consumo de API interna en plantillas Jinja2

Intermedio
FastAPI
FastAPI
Actualizado: 15/09/2025

Creación de endpoints API internos

En las aplicaciones web modernas, es común separar la lógica de presentación de la lógica de datos creando endpoints API que devuelvan información en formato JSON. Estos endpoints internos permiten que las páginas HTML consuman datos de manera dinámica sin recargar toda la página.

Los endpoints API internos son rutas que devuelven datos estructurados en JSON, diseñadas específicamente para ser consumidas por el frontend de la misma aplicación. A diferencia de las rutas que renderizan templates HTML, estos endpoints se centran únicamente en proporcionar datos.

Diferencias entre rutas HTML y API

La principal diferencia radica en el tipo de respuesta que generan:

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# Ruta HTML tradicional
@app.get("/usuarios", response_class=HTMLResponse)
async def pagina_usuarios(request: Request):
    usuarios = [
        {"id": 1, "nombre": "Ana", "email": "ana@ejemplo.com"},
        {"id": 2, "nombre": "Carlos", "email": "carlos@ejemplo.com"}
    ]
    return templates.TemplateResponse("usuarios.html", {
        "request": request, 
        "usuarios": usuarios
    })

# Endpoint API interno
@app.get("/api/usuarios")
async def obtener_usuarios():
    usuarios = [
        {"id": 1, "nombre": "Ana", "email": "ana@ejemplo.com"},
        {"id": 2, "nombre": "Carlos", "email": "carlos@ejemplo.com"}
    ]
    return usuarios

El endpoint API devuelve directamente los datos en formato JSON, mientras que la ruta HTML renderiza un template con esos datos.

Organización de rutas API

Es recomendable agrupar las rutas API bajo un prefijo común para mantener una estructura clara:

from fastapi import APIRouter

# Router específico para endpoints API
api_router = APIRouter(prefix="/api", tags=["API"])

@api_router.get("/productos")
async def listar_productos():
    productos = [
        {"id": 1, "nombre": "Laptop", "precio": 899.99},
        {"id": 2, "nombre": "Mouse", "precio": 29.99},
        {"id": 3, "nombre": "Teclado", "precio": 79.99}
    ]
    return {"productos": productos}

@api_router.get("/productos/{producto_id}")
async def obtener_producto(producto_id: int):
    # Simulamos búsqueda de producto
    productos = {
        1: {"id": 1, "nombre": "Laptop", "precio": 899.99, "stock": 15},
        2: {"id": 2, "nombre": "Mouse", "precio": 29.99, "stock": 50},
        3: {"id": 3, "nombre": "Teclado", "precio": 79.99, "stock": 25}
    }
    
    producto = productos.get(producto_id)
    if not producto:
        return {"error": "Producto no encontrado"}
    
    return {"producto": producto}

# Registrar el router en la aplicación principal
app.include_router(api_router)

Endpoints con diferentes métodos HTTP

Los endpoints API internos pueden manejar diferentes operaciones usando los métodos HTTP apropiados:

# Lista de tareas en memoria para el ejemplo
tareas = [
    {"id": 1, "titulo": "Aprender FastAPI", "completada": False},
    {"id": 2, "titulo": "Crear API interna", "completada": True}
]

@api_router.get("/tareas")
async def listar_tareas():
    return {"tareas": tareas}

@api_router.post("/tareas")
async def crear_tarea(titulo: str):
    nueva_tarea = {
        "id": len(tareas) + 1,
        "titulo": titulo,
        "completada": False
    }
    tareas.append(nueva_tarea)
    return {"mensaje": "Tarea creada", "tarea": nueva_tarea}

@api_router.put("/tareas/{tarea_id}")
async def actualizar_tarea(tarea_id: int, completada: bool = None):
    for tarea in tareas:
        if tarea["id"] == tarea_id:
            if completada is not None:
                tarea["completada"] = completada
            return {"mensaje": "Tarea actualizada", "tarea": tarea}
    
    return {"error": "Tarea no encontrada"}

@api_router.delete("/tareas/{tarea_id}")
async def eliminar_tarea(tarea_id: int):
    for i, tarea in enumerate(tareas):
        if tarea["id"] == tarea_id:
            tarea_eliminada = tareas.pop(i)
            return {"mensaje": "Tarea eliminada", "tarea": tarea_eliminada}
    
    return {"error": "Tarea no encontrada"}

Estructuración de respuestas JSON

Las respuestas consistentes facilitan el consumo desde el frontend. Es buena práctica mantener una estructura predecible:

from typing import List, Dict, Any

@api_router.get("/estadisticas")
async def obtener_estadisticas():
    # Estructura de respuesta consistente
    return {
        "success": True,
        "data": {
            "total_usuarios": 150,
            "usuarios_activos": 98,
            "nuevos_registros": 12
        },
        "timestamp": "2025-01-08T10:30:00Z"
    }

@api_router.get("/buscar")
async def buscar_elementos(query: str):
    # Simulamos búsqueda
    resultados = [
        {"id": 1, "tipo": "usuario", "nombre": "Ana López"},
        {"id": 2, "tipo": "producto", "nombre": "Laptop Gaming"}
    ]
    
    return {
        "success": True,
        "data": {
            "query": query,
            "resultados": resultados,
            "total": len(resultados)
        }
    }

Manejo de errores en endpoints API

Los errores estructurados proporcionan información clara para el frontend:

from fastapi import HTTPException

@api_router.get("/usuario/{usuario_id}")
async def obtener_usuario(usuario_id: int):
    # Simulamos base de datos de usuarios
    usuarios_db = {
        1: {"id": 1, "nombre": "Ana", "activo": True},
        2: {"id": 2, "nombre": "Carlos", "activo": False}
    }
    
    if usuario_id not in usuarios_db:
        return {
            "success": False,
            "error": {
                "code": "USER_NOT_FOUND",
                "message": "El usuario solicitado no existe"
            }
        }
    
    usuario = usuarios_db[usuario_id]
    if not usuario["activo"]:
        return {
            "success": False,
            "error": {
                "code": "USER_INACTIVE", 
                "message": "El usuario está desactivado"
            }
        }
    
    return {
        "success": True,
        "data": {"usuario": usuario}
    }

Endpoints con parámetros de consulta

Los parámetros de consulta permiten filtrar y paginar los resultados:

from typing import Optional

@api_router.get("/articulos")
async def listar_articulos(
    categoria: Optional[str] = None,
    limite: int = 10,
    pagina: int = 1
):
    # Simulamos datos de artículos
    todos_articulos = [
        {"id": 1, "titulo": "Introducción a FastAPI", "categoria": "programacion"},
        {"id": 2, "titulo": "Recetas de cocina", "categoria": "gastronomia"},
        {"id": 3, "titulo": "Desarrollo web moderno", "categoria": "programacion"},
        {"id": 4, "titulo": "Técnicas de fotografía", "categoria": "arte"}
    ]
    
    # Filtrar por categoría si se especifica
    articulos = todos_articulos
    if categoria:
        articulos = [a for a in articulos if a["categoria"] == categoria]
    
    # Calcular paginación
    inicio = (pagina - 1) * limite
    fin = inicio + limite
    articulos_pagina = articulos[inicio:fin]
    
    return {
        "success": True,
        "data": {
            "articulos": articulos_pagina,
            "pagina_actual": pagina,
            "total_articulos": len(articulos),
            "total_paginas": (len(articulos) + limite - 1) // limite
        }
    }

Ejemplo completo de aplicación

Aquí tienes un ejemplo que integra rutas HTML y API trabajando juntas:

from fastapi import FastAPI, Request, APIRouter
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles

app = FastAPI()

# Configuración de archivos estáticos y templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

# Router para rutas HTML
html_router = APIRouter()

# Router para endpoints API
api_router = APIRouter(prefix="/api", tags=["API"])

# Datos en memoria para el ejemplo
noticias = [
    {"id": 1, "titulo": "FastAPI 0.100 disponible", "resumen": "Nueva versión estable"},
    {"id": 2, "titulo": "Python 3.13 lanzado", "resumen": "Mejoras en rendimiento"}
]

# Ruta HTML que renderiza la página principal
@html_router.get("/", response_class=HTMLResponse)
async def pagina_principal(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

# Endpoint API que proporciona datos para la página
@api_router.get("/noticias")
async def obtener_noticias():
    return {
        "success": True,
        "data": {"noticias": noticias}
    }

@api_router.post("/noticias")
async def crear_noticia(titulo: str, resumen: str):
    nueva_noticia = {
        "id": len(noticias) + 1,
        "titulo": titulo,
        "resumen": resumen
    }
    noticias.append(nueva_noticia)
    return {
        "success": True,
        "data": {"noticia": nueva_noticia}
    }

# Registrar routers
app.include_router(html_router)
app.include_router(api_router)

Los endpoints API internos actúan como el puente de datos entre tu aplicación FastAPI y las páginas HTML, proporcionando una arquitectura limpia y escalable donde la lógica de presentación y la lógica de datos están claramente separadas.

Consumo con JavaScript fetch()

Una vez que tenemos nuestros endpoints API internos configurados, el siguiente paso es consumirlos desde las plantillas HTML usando JavaScript. La API fetch() proporciona una interfaz moderna y flexible para realizar peticiones HTTP asíncronas desde el navegador.

Configuración básica en templates Jinja2

Para integrar JavaScript en nuestras plantillas Jinja2, necesitamos estructurar el HTML de manera que separe la presentación de la lógica:

<!-- templates/productos.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Lista de Productos</title>
</head>
<body>
    <div class="container">
        <h1>Productos Disponibles</h1>
        <button id="cargar-productos">Cargar Productos</button>
        <div id="lista-productos">
            <!-- Los productos se cargarán aquí dinámicamente -->
        </div>
    </div>

    <script>
        // JavaScript se incluye al final del body para asegurar que el DOM esté cargado
        document.addEventListener('DOMContentLoaded', function() {
            const botonCargar = document.getElementById('cargar-productos');
            const contenedorProductos = document.getElementById('lista-productos');
            
            botonCargar.addEventListener('click', cargarProductos);
        });
        
        async function cargarProductos() {
            // Implementaremos esta función a continuación
        }
    </script>
</body>
</html>

Sintaxis moderna de fetch() con async/await

La sintaxis moderna de fetch() utiliza async/await para manejar las peticiones asíncronas de manera más legible:

async function cargarProductos() {
    try {
        // Mostrar indicador de carga
        const contenedor = document.getElementById('lista-productos');
        contenedor.innerHTML = '<p>Cargando productos...</p>';
        
        // Realizar petición al endpoint API interno
        const response = await fetch('/api/productos');
        
        // Verificar si la respuesta es exitosa
        if (!response.ok) {
            throw new Error(`Error HTTP: ${response.status}`);
        }
        
        // Convertir respuesta a JSON
        const data = await response.json();
        
        // Procesar y mostrar los datos
        mostrarProductos(data.productos);
        
    } catch (error) {
        console.error('Error al cargar productos:', error);
        document.getElementById('lista-productos').innerHTML = 
            '<p style="color: red;">Error al cargar los productos. Inténtalo de nuevo.</p>';
    }
}

function mostrarProductos(productos) {
    const contenedor = document.getElementById('lista-productos');
    
    if (productos.length === 0) {
        contenedor.innerHTML = '<p>No hay productos disponibles.</p>';
        return;
    }
    
    const html = productos.map(producto => `
        <div class="producto-item" data-id="${producto.id}">
            <h3>${producto.nombre}</h3>
            <p>Precio: $${producto.precio}</p>
            <button onclick="verDetalle(${producto.id})">Ver Detalle</button>
        </div>
    `).join('');
    
    contenedor.innerHTML = html;
}

Peticiones POST con datos de formulario

Para enviar datos al servidor, necesitamos configurar la petición con el método POST y los headers apropiados:

async function crearProducto(event) {
    event.preventDefault(); // Prevenir envío tradicional del formulario
    
    const formulario = event.target;
    const formData = new FormData(formulario);
    
    try {
        const response = await fetch('/api/productos', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                nombre: formData.get('nombre'),
                precio: formData.get('precio'),
                categoria: formData.get('categoria')
            })
        });
        
        if (!response.ok) {
            throw new Error(`Error: ${response.status}`);
        }
        
        const resultado = await response.json();
        
        if (resultado.success) {
            mostrarMensaje('Producto creado exitosamente', 'success');
            formulario.reset();
            cargarProductos(); // Recargar la lista
        } else {
            mostrarMensaje(resultado.error.message, 'error');
        }
        
    } catch (error) {
        console.error('Error al crear producto:', error);
        mostrarMensaje('Error al crear el producto', 'error');
    }
}

function mostrarMensaje(texto, tipo) {
    const mensaje = document.createElement('div');
    mensaje.className = `mensaje ${tipo}`;
    mensaje.textContent = texto;
    
    document.body.appendChild(mensaje);
    
    // Remover mensaje después de 3 segundos
    setTimeout(() => {
        mensaje.remove();
    }, 3000);
}

Manejo de respuestas JSON estructuradas

Cuando nuestros endpoints devuelven respuestas estructuradas, debemos procesarlas adecuadamente:

async function buscarElementos(query) {
    try {
        const response = await fetch(`/api/buscar?query=${encodeURIComponent(query)}`);
        const data = await response.json();
        
        if (data.success) {
            const resultados = data.data.resultados;
            const total = data.data.total;
            
            mostrarResultadosBusqueda(resultados, total, query);
        } else {
            mostrarError(data.error.message);
        }
        
    } catch (error) {
        console.error('Error en búsqueda:', error);
        mostrarError('Error al realizar la búsqueda');
    }
}

function mostrarResultadosBusqueda(resultados, total, query) {
    const contenedor = document.getElementById('resultados-busqueda');
    
    let html = `<h3>Resultados para "${query}" (${total} encontrados)</h3>`;
    
    if (resultados.length === 0) {
        html += '<p>No se encontraron resultados.</p>';
    } else {
        html += '<ul class="lista-resultados">';
        resultados.forEach(resultado => {
            html += `
                <li class="resultado-item">
                    <span class="tipo">[${resultado.tipo}]</span>
                    <span class="nombre">${resultado.nombre}</span>
                </li>
            `;
        });
        html += '</ul>';
    }
    
    contenedor.innerHTML = html;
}

Actualización en tiempo real con fetch()

Para crear interfaces dinámicas e interactivas, podemos combinar eventos del DOM con peticiones fetch():

// Sistema de actualización de estado de tareas
async function alternarEstadoTarea(tareaId, nuevoEstado) {
    try {
        const response = await fetch(`/api/tareas/${tareaId}`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                completada: nuevoEstado
            })
        });
        
        const resultado = await response.json();
        
        if (resultado.success) {
            // Actualizar la interfaz inmediatamente
            actualizarInterfazTarea(tareaId, resultado.data.tarea);
        } else {
            throw new Error(resultado.error.message);
        }
        
    } catch (error) {
        console.error('Error al actualizar tarea:', error);
        // Revertir el cambio visual si hubo error
        revertirCambioTarea(tareaId);
    }
}

function actualizarInterfazTarea(tareaId, tarea) {
    const elementoTarea = document.querySelector(`[data-tarea-id="${tareaId}"]`);
    const checkbox = elementoTarea.querySelector('.tarea-checkbox');
    const titulo = elementoTarea.querySelector('.tarea-titulo');
    
    checkbox.checked = tarea.completada;
    titulo.classList.toggle('completada', tarea.completada);
}

// Configurar event listeners para checkboxes de tareas
document.addEventListener('change', function(event) {
    if (event.target.classList.contains('tarea-checkbox')) {
        const tareaId = parseInt(event.target.dataset.tareaId);
        const nuevoEstado = event.target.checked;
        
        alternarEstadoTarea(tareaId, nuevoEstado);
    }
});

Integración con plantillas Jinja2 y datos dinámicos

Podemos combinar datos del servidor con la funcionalidad de fetch() para crear experiencias híbridas:

<!-- templates/dashboard.html -->
<div class="dashboard">
    <h1>Dashboard - {{ usuario.nombre }}</h1>
    
    <div class="estadisticas" id="estadisticas-container">
        <!-- Las estadísticas se cargarán dinámicamente -->
    </div>
    
    <div class="acciones-rapidas">
        <button onclick="actualizarEstadisticas()">Actualizar</button>
        <button onclick="exportarDatos()">Exportar</button>
    </div>
</div>

<script>
    // Datos del servidor disponibles en JavaScript
    const usuarioActual = {
        id: {{ usuario.id }},
        nombre: "{{ usuario.nombre }}",
        rol: "{{ usuario.rol }}"
    };
    
    // Cargar estadísticas al iniciar la página
    document.addEventListener('DOMContentLoaded', function() {
        actualizarEstadisticas();
        
        // Actualizar automáticamente cada 30 segundos
        setInterval(actualizarEstadisticas, 30000);
    });
    
    async function actualizarEstadisticas() {
        try {
            const response = await fetch('/api/estadisticas');
            const data = await response.json();
            
            if (data.success) {
                mostrarEstadisticas(data.data);
            }
            
        } catch (error) {
            console.error('Error al actualizar estadísticas:', error);
        }
    }
    
    function mostrarEstadisticas(stats) {
        const container = document.getElementById('estadisticas-container');
        container.innerHTML = `
            <div class="stat-card">
                <h3>Total Usuarios</h3>
                <p class="stat-number">${stats.total_usuarios}</p>
            </div>
            <div class="stat-card">
                <h3>Usuarios Activos</h3>
                <p class="stat-number">${stats.usuarios_activos}</p>
            </div>
            <div class="stat-card">
                <h3>Nuevos Registros</h3>
                <p class="stat-number">${stats.nuevos_registros}</p>
            </div>
        `;
    }
</script>

Manejo de errores y estados de carga

Un manejo robusto de errores mejora significativamente la experiencia del usuario:

class APIClient {
    constructor(baseURL = '/api') {
        this.baseURL = baseURL;
    }
    
    async request(endpoint, options = {}) {
        const url = `${this.baseURL}${endpoint}`;
        const config = {
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            },
            ...options
        };
        
        try {
            const response = await fetch(url, config);
            
            if (!response.ok) {
                const errorData = await response.json().catch(() => ({}));
                throw new APIError(
                    errorData.error?.message || `Error HTTP: ${response.status}`,
                    response.status,
                    errorData.error?.code
                );
            }
            
            return await response.json();
            
        } catch (error) {
            if (error instanceof APIError) {
                throw error;
            }
            throw new APIError('Error de conexión', 0, 'CONNECTION_ERROR');
        }
    }
    
    async get(endpoint, params = {}) {
        const queryString = new URLSearchParams(params).toString();
        const url = queryString ? `${endpoint}?${queryString}` : endpoint;
        return this.request(url);
    }
    
    async post(endpoint, data) {
        return this.request(endpoint, {
            method: 'POST',
            body: JSON.stringify(data)
        });
    }
}

class APIError extends Error {
    constructor(message, status, code) {
        super(message);
        this.status = status;
        this.code = code;
        this.name = 'APIError';
    }
}

// Uso del cliente API
const api = new APIClient();

async function cargarDatosConManejo() {
    const loadingElement = document.getElementById('loading');
    const errorElement = document.getElementById('error');
    const contentElement = document.getElementById('content');
    
    try {
        // Mostrar estado de carga
        loadingElement.style.display = 'block';
        errorElement.style.display = 'none';
        
        const data = await api.get('/productos');
        
        if (data.success) {
            renderizarContenido(data.data);
            contentElement.style.display = 'block';
        }
        
    } catch (error) {
        console.error('Error:', error);
        errorElement.textContent = error.message;
        errorElement.style.display = 'block';
        
    } finally {
        loadingElement.style.display = 'none';
    }
}

La integración de fetch() con plantillas Jinja2 permite crear aplicaciones web dinámicas que combinan la renderización del lado del servidor con la interactividad del lado del cliente, ofreciendo una experiencia de usuario fluida y responsiva.

Fuentes y referencias

Documentación oficial y recursos externos para profundizar en FastAPI

Documentación oficial de FastAPI
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, FastAPI es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de FastAPI

Explora más contenido relacionado con FastAPI y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

  • Entender la diferencia entre rutas HTML y endpoints API internos en FastAPI.
  • Crear y organizar endpoints API con diferentes métodos HTTP y manejo de errores.
  • Consumir endpoints API desde plantillas Jinja2 utilizando JavaScript fetch() con async/await.
  • Implementar manejo de respuestas JSON estructuradas y estados de carga en el frontend.
  • Integrar datos dinámicos del servidor con la lógica cliente para interfaces interactivas y actualizaciones en tiempo real.