API REST con Express.js + MongoDB

CRUD completo para sistema de inventario con autenticación JWT y documentación Swagger

🚀 Introducción

Este manual te guiará en la creación de una API RESTful completa para gestión de inventario utilizando:

1. Configuración del Proyecto

Terminal
# Inicializar proyecto
npm init -y

# Instalar dependencias principales
npm install express mongoose cors jsonwebtoken bcryptjs dotenv

# Dependencias para desarrollo
npm install --save-dev nodemon swagger-jsdoc swagger-ui-express

Estructura del Proyecto

/
├── config/
│   └── db.js        # Conexión a MongoDB
├── models/
│   └── Product.js   # Modelo de Mongoose
├── routes/
│   ├── auth.js      # Rutas de autenticación
│   └── products.js  # Rutas de productos
├── controllers/
│   ├── auth.js      # Lógica de autenticación
│   └── products.js  # Lógica de productos
├── middlewares/
│   ├── auth.js      # Middleware de JWT
│   └── error.js     # Manejo de errores
├── utils/
│   └── apiFeatures.js # Funciones auxiliares
├── app.js           # Configuración de Express
└── server.js        # Punto de entrada

2. Conexión a MongoDB

config/db.js
const mongoose = require('mongoose');
require('dotenv').config();

const connectDB = async () => {
    try {
        await mongoose.connect(process.env.MONGO_URI, {
            useNewUrlParser: true,
            useUnifiedTopology: true,
            useCreateIndex: true,
            useFindAndModify: false
        });
        console.log('MongoDB Connected...');
    } catch (err) {
        console.error('Database connection error:', err.message);
        process.exit(1);
    }
};

module.exports = connectDB;
.env
MONGO_URI=mongodb+srv://usuario:password@cluster0.mongodb.net/inventario?retryWrites=true&w=majority
JWT_SECRET=miSuperSecreto123
JWT_EXPIRE=30d

🔍 Buenas Prácticas MongoDB

3. Modelo de Producto con Mongoose

models/Product.js
const mongoose = require('mongoose');
const validator = require('validator');

const productSchema = new mongoose.Schema({
    name: {
        type: String,
        required: [true, 'El nombre es obligatorio'],
        trim: true,
        maxlength: [100, 'El nombre no puede exceder 100 caracteres'],
        unique: true
    },
    description: {
        type: String,
        maxlength: [500, 'La descripción no puede exceder 500 caracteres']
    },
    price: {
        type: Number,
        required: [true, 'El precio es obligatorio'],
        min: [0, 'El precio no puede ser negativo'],
        set: val => Math.round(val * 100) / 100 // 2 decimales
    },
    stock: {
        type: Number,
        default: 0,
        min: [0, 'El stock no puede ser negativo']
    },
    category: {
        type: String,
        enum: {
            values: ['electronics', 'clothing', 'food', 'other'],
            message: 'Categoría no válida'
        }
    },
    createdAt: {
        type: Date,
        default: Date.now
    }
}, {
    toJSON: { virtuals: true },
    toObject: { virtuals: true }
});

// Índice para búsquedas por nombre y categoría
productSchema.index({ name: 1, category: 1 });

// Middleware para hooks
productSchema.pre('save', function(next) {
    console.log(`Guardando producto: ${this.name}`);
    next();
});

const Product = mongoose.model('Product', productSchema);

module.exports = Product;

📌 Características del Modelo

4. Controlador de Productos

controllers/products.js
const Product = require('../models/Product');
const APIFeatures = require('../utils/apiFeatures');
const AppError = require('../utils/appError');

// @desc    Obtener todos los productos
// @route   GET /api/v1/products
// @access  Public
exports.getProducts = async (req, res, next) => {
    try {
        const features = new APIFeatures(Product.find(), req.query)
            .filter()
            .sort()
            .limitFields()
            .paginate();
        
        const products = await features.query;

        res.status(200).json({
            success: true,
            count: products.length,
            data: products
        });
    } catch (err) {
        next(err);
    }
};

// @desc    Crear nuevo producto
// @route   POST /api/v1/products
// @access  Private/Admin
exports.createProduct = async (req, res, next) => {
    try {
        const product = await Product.create(req.body);

        res.status(201).json({
            success: true,
            data: product
        });
    } catch (err) {
        next(err);
    }
};

// @desc    Actualizar producto
// @route   PUT /api/v1/products/:id
// @access  Private/Admin
exports.updateProduct = async (req, res, next) => {
    try {
        const product = await Product.findByIdAndUpdate(
            req.params.id, 
            req.body, 
            { 
                new: true,
                runValidators: true
            }
        );

        if (!product) {
            return next(new AppError('Producto no encontrado', 404));
        }

        res.status(200).json({
            success: true,
            data: product
        });
    } catch (err) {
        next(err);
    }
};

// @desc    Eliminar producto
// @route   DELETE /api/v1/products/:id
// @access  Private/Admin
exports.deleteProduct = async (req, res, next) => {
    try {
        const product = await Product.findByIdAndDelete(req.params.id);

        if (!product) {
            return next(new AppError('Producto no encontrado', 404));
        }

        res.status(200).json({
            success: true,
            data: {}
        });
    } catch (err) {
        next(err);
    }
};

🔧 Utilidad API Features

Clase auxiliar para filtrado, ordenación, paginación y selección de campos:

class APIFeatures {
    constructor(query, queryStr) {
        this.query = query;
        this.queryStr = queryStr;
    }

    filter() {
        const queryObj = { ...this.queryStr };
        const excludedFields = ['page', 'sort', 'limit', 'fields'];
        excludedFields.forEach(el => delete queryObj[el]);

        let queryStr = JSON.stringify(queryObj);
        queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/g, match => `$${match}`);

        this.query = this.query.find(JSON.parse(queryStr));
        return this;
    }

    sort() {
        if (this.queryStr.sort) {
            const sortBy = this.queryStr.sort.split(',').join(' ');
            this.query = this.query.sort(sortBy);
        } else {
            this.query = this.query.sort('-createdAt');
        }
        return this;
    }

    limitFields() {
        if (this.queryStr.fields) {
            const fields = this.queryStr.fields.split(',').join(' ');
            this.query = this.query.select(fields);
        } else {
            this.query = this.query.select('-__v');
        }
        return this;
    }

    paginate() {
        const page = this.queryStr.page * 1 || 1;
        const limit = this.queryStr.limit * 1 || 100;
        const skip = (page - 1) * limit;

        this.query = this.query.skip(skip).limit(limit);
        return this;
    }
}

5. Autenticación con JWT

middlewares/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const AppError = require('../utils/appError');

// Proteger rutas
exports.protect = async (req, res, next) => {
    let token;
    
    if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
        token = req.headers.authorization.split(' ')[1];
    }

    if (!token) {
        return next(new AppError('No autorizado - Inicie sesión', 401));
    }

    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.user = await User.findById(decoded.id);
        next();
    } catch (err) {
        return next(new AppError('Token inválido', 401));
    }
};

// Restringir a ciertos roles
exports.restrictTo = (...roles) => {
    return (req, res, next) => {
        if (!roles.includes(req.user.role)) {
            return next(new AppError('No tiene permisos para esta acción', 403));
        }
        next();
    };
};
controllers/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const AppError = require('../utils/appError');

// @desc    Iniciar sesión
// @route   POST /api/v1/auth/login
// @access  Public
exports.login = async (req, res, next) => {
    const { email, password } = req.body;

    if (!email || !password) {
        return next(new AppError('Por favor ingrese email y contraseña', 400));
    }

    const user = await User.findOne({ email }).select('+password');

    if (!user || !(await user.comparePassword(password))) {
        return next(new AppError('Credenciales incorrectas', 401));
    }

    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
        expiresIn: process.env.JWT_EXPIRE
    });

    res.status(200).json({
        success: true,
        token
    });
};

6. Documentación con Swagger

utils/swagger.js
const swaggerJsdoc = require('swagger-jsdoc');

const options = {
    definition: {
        openapi: '3.0.0',
        info: {
            title: 'API de Inventario',
            version: '1.0.0',
            description: 'Documentación para la API de gestión de inventario',
        },
        servers: [
            { url: 'http://localhost:3000/api/v1' }
        ],
        components: {
            securitySchemes: {
                bearerAuth: {
                    type: 'http',
                    scheme: 'bearer',
                    bearerFormat: 'JWT'
                }
            },
            schemas: {
                Product: {
                    type: 'object',
                    required: ['name', 'price'],
                    properties: {
                        name: { type: 'string', example: 'Laptop HP' },
                        price: { type: 'number', example: 1299.99 },
                        stock: { type: 'integer', example: 15 }
                    }
                }
            }
        }
    },
    apis: ['./routes/*.js']
};

const specs = swaggerJsdoc(options);

module.exports = specs;
app.js (fragmento)
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./utils/swagger');

// Documentación
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
routes/products.js (ejemplo documentado)
/**
 * @swagger
 * /products:
 *   get:
 *     summary: Obtener todos los productos
 *     tags: [Products]
 *     parameters:
 *       - in: query
 *         name: category
 *         schema:
 *           type: string
 *         description: Filtrar por categoría
 *     responses:
 *       200:
 *         description: Lista de productos
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 $ref: '#/components/schemas/Product'
 */
router.get('/', productController.getProducts);

7. Configuración Completa

app.js
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const connectDB = require('./config/db');
const errorHandler = require('./middlewares/error');

// Importar rutas
const productRoutes = require('./routes/products');
const authRoutes = require('./routes/auth');

// Conectar a MongoDB
connectDB();

// Inicializar Express
const app = express();

// Middlewares
app.use(cors());
app.use(express.json());

// Logger en desarrollo
if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
}

// Montar rutas
app.use('/api/v1/products', productRoutes);
app.use('/api/v1/auth', authRoutes);

// Manejo de errores
app.use(errorHandler);

module.exports = app;
server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
    console.log(`Servidor corriendo en puerto ${PORT} en modo ${process.env.NODE_ENV}`);
});

📡 Endpoints Disponibles

GET /api/v1/products

Obtener todos los productos (filtros, ordenación, paginación)

POST /api/v1/products

Crear nuevo producto (requiere autenticación JWT)

PUT /api/v1/products/:id

Actualizar producto (requiere autenticación JWT)

DELETE /api/v1/products/:id

Eliminar producto (requiere autenticación JWT y rol admin)

POST /api/v1/auth/login

Autenticación de usuario (obtener token JWT)

GET /api-docs

Documentación interactiva de la API