Construcción de una API REST sencilla

Intermedio
Node
Node
Actualizado: 03/09/2025

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 endpoint
  • routes/: Define las rutas y las conecta con los controladores correspondientes
  • models/: Representa las estructuras de datos (en este caso, para almacenamiento en memoria)
  • middlewares/: Funciones intermedias personalizadas para la API
  • config/: 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 - 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, 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.