Estructura básica de un proyecto con Express
Una API REST requiere una organización específica del código que facilite su mantenimiento y escalabilidad. A diferencia de un servidor básico de Express, una API necesita separar claramente las responsabilidades y seguir patrones arquitectónicos que permitan un desarrollo ordenado.
Organización de directorios
La estructura de carpetas es fundamental para mantener el código organizado. Una estructura típica para una API REST incluye las siguientes carpetas principales:
mi-api-rest/
├── src/
│ ├── controllers/
│ ├── routes/
│ ├── models/
│ ├── middlewares/
│ └── config/
├── package.json
├── .env
└── server.js
La carpeta src/
contiene todo el código fuente de la aplicación. Esta separación permite distinguir claramente entre el código de la aplicación y los archivos de configuración del proyecto.
Separación por responsabilidades
Cada directorio tiene una función específica dentro de la arquitectura:
controllers/
: Contiene la lógica de negocio de cada endpointroutes/
: Define las rutas y las conecta con los controladores correspondientesmodels/
: Representa las estructuras de datos (en este caso, para almacenamiento en memoria)middlewares/
: Funciones intermedias personalizadas para la APIconfig/
: Configuraciones de la aplicación como variables de entorno
Archivo principal del servidor
El archivo server.js
actúa como punto de entrada de la aplicación y debe mantenerse limpio:
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const userRoutes = require('./src/routes/userRoutes');
const productRoutes = require('./src/routes/productRoutes');
const app = express();
const PORT = process.env.PORT || 3000;
// Middlewares globales
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Rutas de la API
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
// Ruta base de la API
app.get('/api', (req, res) => {
res.json({
message: 'API REST funcionando correctamente',
version: '1.0.0'
});
});
app.listen(PORT, () => {
console.log(`Servidor ejecutándose en http://localhost:${PORT}`);
});
Este enfoque centraliza la configuración inicial y delega las responsabilidades específicas a los módulos correspondientes.
Organización de rutas
Las rutas se organizan por recursos o entidades. Cada archivo de ruta maneja un conjunto específico de endpoints:
// src/routes/userRoutes.js
const express = require('express');
const userController = require('../controllers/userController');
const router = express.Router();
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', userController.createUser);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);
module.exports = router;
Esta modularización permite que cada archivo se enfoque en un recurso específico, facilitando el mantenimiento y la comprensión del código.
Controladores para la lógica de negocio
Los controladores contienen la lógica específica de cada endpoint, separada de las definiciones de ruta:
// src/controllers/userController.js
const userModel = require('../models/userModel');
const userController = {
getAllUsers: (req, res) => {
try {
const users = userModel.getAllUsers();
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
},
getUserById: (req, res) => {
try {
const { id } = req.params;
const user = userModel.getUserById(id);
if (!user) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
}
};
module.exports = userController;
Configuración de variables de entorno
El archivo .env
centraliza la configuración sensible y específica del entorno:
PORT=3000
NODE_ENV=development
API_VERSION=v1
Esta práctica permite cambiar configuraciones sin modificar el código fuente, facilitando el despliegue en diferentes entornos.
Ventajas de esta estructura
Esta organización aporta varios beneficios importantes:
- Escalabilidad: Facilita añadir nuevos recursos sin afectar el código existente
- Mantenimiento: Cada archivo tiene una responsabilidad clara y bien definida
- Colaboración: Múltiples desarrolladores pueden trabajar simultáneamente sin conflictos
- Testeo: Permite realizar pruebas unitarias de cada componente por separado
- Reutilización: Los controladores y modelos pueden reutilizarse en diferentes rutas
La separación clara entre rutas, controladores y modelos sigue el patrón MVC (Modelo-Vista-Controlador) adaptado para APIs, donde las vistas se reemplazan por las respuestas JSON.
CRUD en memoria
Las operaciones CRUD (Create, Read, Update, Delete) constituyen la base de cualquier API REST. Implementar estas operaciones utilizando almacenamiento en memoria permite crear una API funcional sin depender de una base de datos externa, ideal para prototipos y desarrollo inicial.
Modelo de datos en memoria
El primer paso es crear un modelo de datos que simule una base de datos utilizando estructuras de JavaScript. Un array actúa como tabla principal y un contador genera identificadores únicos:
// src/models/userModel.js
let users = [
{ id: 1, name: 'Ana García', email: 'ana@email.com', age: 25 },
{ id: 2, name: 'Carlos López', email: 'carlos@email.com', age: 30 },
{ id: 3, name: 'María Rodríguez', email: 'maria@email.com', age: 28 }
];
let nextId = 4;
const userModel = {
getAllUsers: () => users,
getUserById: (id) => {
return users.find(user => user.id === parseInt(id));
},
createUser: (userData) => {
const newUser = {
id: nextId++,
name: userData.name,
email: userData.email,
age: userData.age
};
users.push(newUser);
return newUser;
},
updateUser: (id, userData) => {
const index = users.findIndex(user => user.id === parseInt(id));
if (index === -1) return null;
users[index] = { ...users[index], ...userData };
return users[index];
},
deleteUser: (id) => {
const index = users.findIndex(user => user.id === parseInt(id));
if (index === -1) return null;
const deletedUser = users[index];
users.splice(index, 1);
return deletedUser;
}
};
module.exports = userModel;
Esta implementación utiliza métodos de array nativos de JavaScript para realizar las operaciones básicas de manera eficiente.
Operación CREATE - Crear recursos
La operación CREATE añade nuevos elementos al almacén de datos. El controlador debe validar los datos recibidos y asignar un identificador único:
// src/controllers/userController.js
const userModel = require('../models/userModel');
const createUser = (req, res) => {
try {
const { name, email, age } = req.body;
// Validación básica de datos requeridos
if (!name || !email) {
return res.status(400).json({
error: 'Los campos name y email son obligatorios'
});
}
// Validación de formato de email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
error: 'El formato del email no es válido'
});
}
// Verificar email único
const existingUser = userModel.getAllUsers().find(u => u.email === email);
if (existingUser) {
return res.status(409).json({
error: 'Ya existe un usuario con ese email'
});
}
const newUser = userModel.createUser({ name, email, age });
res.status(201).json(newUser);
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
};
Operación READ - Consultar recursos
Las operaciones READ permiten obtener datos del almacén. Se implementan dos variantes principales: obtener todos los recursos y obtener un recurso específico:
const getAllUsers = (req, res) => {
try {
const users = userModel.getAllUsers();
// Soporte para paginación básica
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedUsers = users.slice(startIndex, endIndex);
res.json({
users: paginatedUsers,
total: users.length,
page: page,
totalPages: Math.ceil(users.length / limit)
});
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
};
const getUserById = (req, res) => {
try {
const { id } = req.params;
// Validar que el ID sea un número
if (isNaN(id)) {
return res.status(400).json({
error: 'El ID debe ser un número válido'
});
}
const user = userModel.getUserById(id);
if (!user) {
return res.status(404).json({
error: 'Usuario no encontrado'
});
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
};
Operación UPDATE - Actualizar recursos
La operación UPDATE modifica recursos existentes. Es importante mantener la integridad de los datos y permitir actualizaciones parciales:
const updateUser = (req, res) => {
try {
const { id } = req.params;
const updateData = req.body;
// Validar ID numérico
if (isNaN(id)) {
return res.status(400).json({
error: 'El ID debe ser un número válido'
});
}
// Verificar que el usuario existe
const existingUser = userModel.getUserById(id);
if (!existingUser) {
return res.status(404).json({
error: 'Usuario no encontrado'
});
}
// Validar email único si se está actualizando
if (updateData.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(updateData.email)) {
return res.status(400).json({
error: 'El formato del email no es válido'
});
}
const userWithEmail = userModel.getAllUsers()
.find(u => u.email === updateData.email && u.id !== parseInt(id));
if (userWithEmail) {
return res.status(409).json({
error: 'Ya existe otro usuario con ese email'
});
}
}
// Filtrar campos no permitidos para actualización
const allowedFields = ['name', 'email', 'age'];
const filteredData = Object.keys(updateData)
.filter(key => allowedFields.includes(key))
.reduce((obj, key) => {
obj[key] = updateData[key];
return obj;
}, {});
const updatedUser = userModel.updateUser(id, filteredData);
res.json(updatedUser);
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
};
Operación DELETE - Eliminar recursos
La operación DELETE remueve recursos del almacén de datos. Es crucial verificar la existencia del recurso antes de eliminarlo:
const deleteUser = (req, res) => {
try {
const { id } = req.params;
// Validar ID numérico
if (isNaN(id)) {
return res.status(400).json({
error: 'El ID debe ser un número válido'
});
}
const deletedUser = userModel.deleteUser(id);
if (!deletedUser) {
return res.status(404).json({
error: 'Usuario no encontrado'
});
}
// Respuesta exitosa sin contenido
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
};
module.exports = {
getAllUsers,
getUserById,
createUser,
updateUser,
deleteUser
};
Funcionalidades adicionales
Para enriquecer la API, se pueden implementar funcionalidades de búsqueda y filtrado directamente sobre los datos en memoria:
// Método adicional en el modelo
searchUsers: (query) => {
const searchTerm = query.toLowerCase();
return users.filter(user =>
user.name.toLowerCase().includes(searchTerm) ||
user.email.toLowerCase().includes(searchTerm)
);
},
filterUsersByAge: (minAge, maxAge) => {
return users.filter(user => {
const age = user.age || 0;
return age >= minAge && age <= maxAge;
});
}
// Controlador para búsqueda
const searchUsers = (req, res) => {
try {
const { q, minAge, maxAge } = req.query;
let results = userModel.getAllUsers();
if (q) {
results = userModel.searchUsers(q);
}
if (minAge || maxAge) {
const min = parseInt(minAge) || 0;
const max = parseInt(maxAge) || 999;
results = userModel.filterUsersByAge(min, max);
}
res.json({
users: results,
total: results.length,
query: { q, minAge, maxAge }
});
} catch (error) {
res.status(500).json({ error: 'Error interno del servidor' });
}
};
Ventajas del almacenamiento en memoria
El almacenamiento en memoria ofrece varios beneficios durante el desarrollo:
- Simplicidad: No requiere configuración de base de datos externa
- Rapidez: Las operaciones son extremadamente rápidas al trabajar directamente con la RAM
- Prototipado: Ideal para validar la lógica de negocio antes de integrar persistencia real
- Testing: Facilita las pruebas unitarias al tener un estado predecible
Sin embargo, es importante recordar que los datos se pierden al reiniciar el servidor, por lo que esta aproximación es temporal y orientada al desarrollo inicial de la API.
Pruebas de API con Postman o Thunder Client
Una vez implementada nuestra API REST, es fundamental probarla para verificar que todos los endpoints funcionan correctamente. Las herramientas de testing de APIs nos permiten enviar peticiones HTTP de forma controlada y examinar las respuestas sin necesidad de crear una interfaz frontend.
Herramientas de testing recomendadas
Thunder Client es una extensión ligera de Visual Studio Code que proporciona funcionalidades completas para probar APIs directamente desde el editor. Su integración nativa con VS Code la convierte en una opción ideal para desarrolladores que ya trabajan en este entorno.
Postman es una aplicación independiente más robusta que ofrece características avanzadas como colecciones organizadas, scripts de automatización y colaboración en equipo. Aunque requiere instalación por separado, proporciona un ecosistema completo para el desarrollo de APIs.
Configuración inicial del entorno de pruebas
Antes de realizar las pruebas, es necesario configurar el entorno base que utilizaremos. Esto incluye definir la URL base de nuestra API y las cabeceras comunes que se utilizarán en todas las peticiones.
En Thunder Client, creamos un nuevo entorno con las siguientes variables:
{
"baseUrl": "http://localhost:3000/api",
"contentType": "application/json"
}
Para Postman, establecemos variables similares en el entorno de desarrollo:
base_url
:http://localhost:3000/api
content_type
:application/json
Pruebas de operaciones READ
Las operaciones de lectura son las más sencillas de probar, ya que no requieren envío de datos en el cuerpo de la petición. Comenzamos probando el endpoint que obtiene todos los usuarios:
Petición GET - Obtener todos los usuarios:
GET {{baseUrl}}/users
Content-Type: {{contentType}}
La respuesta esperada debe incluir el array de usuarios con la estructura de paginación implementada:
{
"users": [
{
"id": 1,
"name": "Ana García",
"email": "ana@email.com",
"age": 25
}
],
"total": 3,
"page": 1,
"totalPages": 1
}
Petición GET - Obtener usuario específico:
GET {{baseUrl}}/users/1
Content-Type: {{contentType}}
Esta petición debe devolver únicamente los datos del usuario solicitado o un error 404 si el ID no existe.
Pruebas de operación CREATE
La operación CREATE requiere enviar datos en el cuerpo de la petición utilizando el método POST. Es importante probar tanto casos exitosos como escenarios de error:
Petición POST - Crear usuario válido:
POST {{baseUrl}}/users
Content-Type: {{contentType}}
{
"name": "Pedro Martínez",
"email": "pedro@email.com",
"age": 35
}
La respuesta exitosa debe incluir el nuevo usuario creado con su ID asignado y código de estado 201:
{
"id": 4,
"name": "Pedro Martínez",
"email": "pedro@email.com",
"age": 35
}
Pruebas de validación - Datos incompletos:
POST {{baseUrl}}/users
Content-Type: {{contentType}}
{
"name": "Usuario Incompleto"
}
Esta petición debe generar un error 400 indicando que faltan campos obligatorios.
Pruebas de validación - Email duplicado:
POST {{baseUrl}}/users
Content-Type: {{contentType}}
{
"name": "Nuevo Usuario",
"email": "ana@email.com",
"age": 30
}
Debe devolver un error 409 informando que el email ya está en uso.
Pruebas de operación UPDATE
Las pruebas de actualización verifican que los cambios se apliquen correctamente y que las validaciones funcionen durante las modificaciones:
Petición PUT - Actualización completa:
PUT {{baseUrl}}/users/1
Content-Type: {{contentType}}
{
"name": "Ana García Actualizada",
"email": "ana.nueva@email.com",
"age": 26
}
Petición PATCH - Actualización parcial:
PUT {{baseUrl}}/users/2
Content-Type: {{contentType}}
{
"age": 31
}
Es importante verificar que solo se actualicen los campos enviados y que el resto permanezcan sin cambios.
Pruebas de operación DELETE
La operación DELETE debe probarse verificando tanto la eliminación exitosa como los casos donde el recurso no existe:
Petición DELETE - Eliminación exitosa:
DELETE {{baseUrl}}/users/3
Una eliminación exitosa debe devolver código 204 sin contenido en el cuerpo de la respuesta.
Petición DELETE - Recurso inexistente:
DELETE {{baseUrl}}/users/999
Debe devolver error 404 indicando que el usuario no fue encontrado.
Organización de pruebas en colecciones
Para mantener las pruebas organizadas, es recomendable agruparlas en colecciones según la funcionalidad:
Colección "Usuarios - CRUD Básico":
- GET - Listar usuarios
- GET - Obtener usuario por ID
- POST - Crear usuario
- PUT - Actualizar usuario
- DELETE - Eliminar usuario
Colección "Usuarios - Casos de Error":
- POST - Crear usuario con datos inválidos
- GET - Buscar usuario inexistente
- PUT - Actualizar usuario inexistente
- DELETE - Eliminar usuario inexistente
Pruebas de funcionalidades adicionales
Si implementamos funcionalidades de búsqueda y filtrado, también debemos crear pruebas específicas para estos endpoints:
Petición GET - Búsqueda por texto:
GET {{baseUrl}}/users/search?q=Ana
Content-Type: {{contentType}}
Petición GET - Filtro por edad:
GET {{baseUrl}}/users/search?minAge=25&maxAge=35
Content-Type: {{contentType}}
Verificación de respuestas y códigos de estado
Durante las pruebas, es fundamental verificar tanto el contenido de las respuestas como los códigos de estado HTTP:
- 200 OK: Para operaciones GET y PUT exitosas
- 201 Created: Para operaciones POST exitosas
- 204 No Content: Para operaciones DELETE exitosas
- 400 Bad Request: Para datos de entrada inválidos
- 404 Not Found: Para recursos que no existen
- 409 Conflict: Para conflictos como emails duplicados
- 500 Internal Server Error: Para errores del servidor
Automatización de pruebas con scripts
Tanto Postman como Thunder Client permiten agregar scripts de validación que verifican automáticamente las respuestas:
// Script de prueba para verificar creación de usuario
pm.test("Usuario creado exitosamente", function () {
pm.response.to.have.status(201);
pm.response.to.be.json;
const user = pm.response.json();
pm.expect(user).to.have.property('id');
pm.expect(user.name).to.equal('Pedro Martínez');
pm.expect(user.email).to.equal('pedro@email.com');
});
Flujo de pruebas completo
Un flujo de pruebas integral debe seguir esta secuencia lógica:
- 1. Verificar estado inicial: Obtener lista de usuarios existentes
- 2. Crear nuevo recurso: Añadir usuario con datos válidos
- 3. Verificar creación: Buscar el usuario recién creado por ID
- 4. Actualizar recurso: Modificar datos del usuario
- 5. Verificar actualización: Confirmar que los cambios se aplicaron
- 6. Eliminar recurso: Borrar el usuario de prueba
- 7. Verificar eliminación: Confirmar que el usuario ya no existe
Esta metodología sistemática garantiza que todos los aspectos de la API funcionen correctamente y que los cambios en una operación no afecten negativamente a las demás.
Ventajas del testing de APIs
El testing sistemático de APIs proporciona beneficios significativos:
- Detección temprana de errores: Identifica problemas antes de que lleguen a producción
- Validación de contratos: Asegura que la API cumple con las especificaciones definidas
- Regresión controlada: Detecta si nuevos cambios rompen funcionalidades existentes
- Documentación viva: Las pruebas sirven como ejemplos de uso de la API
- Confianza en despliegues: Permite realizar cambios con mayor seguridad
El uso de herramientas especializadas como Thunder Client o Postman transforma el desarrollo de APIs de un proceso manual propenso a errores en un flujo de trabajo sistemático y confiable.
Fuentes y referencias
Documentación oficial y recursos externos para profundizar en Node
Documentación oficial de Node
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, Node 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 Node
Explora más contenido relacionado con Node y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender la estructura básica y organización de un proyecto API REST con Express.
- Implementar operaciones CRUD utilizando almacenamiento en memoria.
- Aplicar buenas prácticas de separación de responsabilidades mediante controladores, rutas y modelos.
- Configurar y utilizar variables de entorno para gestionar la configuración.
- Realizar pruebas funcionales de la API usando herramientas especializadas como Postman o Thunder Client.