Node.js & Express

Building a REST API Project - Part 3

35 min Lesson 38 of 40

Building a Complete REST API Project - Part 3

In this final lesson of our REST API project series, we'll add comprehensive testing, API documentation, enhanced security hardening, and deploy our application. This will transform our project into a production-ready REST API.

Step 1: Setting Up Testing Environment

Install testing dependencies:

npm install --save-dev jest supertest @types/jest
npm install --save-dev mongodb-memory-server

Update package.json with test scripts:

"scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest --watchAll --verbose",
    "test:coverage": "jest --coverage"
},
"jest": {
    "testEnvironment": "node",
    "coveragePathIgnorePatterns": ["/node_modules/"]
}

Step 2: Test Setup

Create test setup file (tests/setup.js):

const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongoServer;

// Connect to in-memory database before tests
beforeAll(async () => {
    mongoServer = await MongoMemoryServer.create();
    const mongoUri = mongoServer.getUri();

    await mongoose.connect(mongoUri);
});

// Clear database between tests
afterEach(async () => {
    const collections = mongoose.connection.collections;
    for (const key in collections) {
        await collections[key].deleteMany();
    }
});

// Disconnect and stop in-memory database after tests
afterAll(async () => {
    await mongoose.disconnect();
    await mongoServer.stop();
});

module.exports = { mongoServer };

Step 3: Authentication Tests

Create authentication tests (tests/auth.test.js):

const request = require('supertest');
const app = require('../src/app');
const User = require('../src/models/User');

require('./setup');

describe('Authentication Endpoints', () => {
    describe('POST /api/auth/register', () => {
        it('should register a new user successfully', async () => {
            const response = await request(app)
                .post('/api/auth/register')
                .send({
                    name: 'John Doe',
                    email: 'john@example.com',
                    password: 'password123'
                })
                .expect(201);

            expect(response.body.success).toBe(true);
            expect(response.body.data.user).toHaveProperty('email', 'john@example.com');
            expect(response.body.data).toHaveProperty('token');
            expect(response.body.data.user).not.toHaveProperty('password');
        });

        it('should fail with duplicate email', async () => {
            await User.create({
                name: 'Jane Doe',
                email: 'jane@example.com',
                password: 'password123'
            });

            const response = await request(app)
                .post('/api/auth/register')
                .send({
                    name: 'Another User',
                    email: 'jane@example.com',
                    password: 'password456'
                })
                .expect(409);

            expect(response.body.success).toBe(false);
        });

        it('should fail with invalid data', async () => {
            const response = await request(app)
                .post('/api/auth/register')
                .send({
                    name: 'J',
                    email: 'invalid-email',
                    password: '123'
                })
                .expect(400);

            expect(response.body.success).toBe(false);
        });
    });

    describe('POST /api/auth/login', () => {
        beforeEach(async () => {
            await request(app)
                .post('/api/auth/register')
                .send({
                    name: 'Test User',
                    email: 'test@example.com',
                    password: 'password123'
                });
        });

        it('should login successfully with correct credentials', async () => {
            const response = await request(app)
                .post('/api/auth/login')
                .send({
                    email: 'test@example.com',
                    password: 'password123'
                })
                .expect(200);

            expect(response.body.success).toBe(true);
            expect(response.body.data).toHaveProperty('token');
        });

        it('should fail with incorrect password', async () => {
            const response = await request(app)
                .post('/api/auth/login')
                .send({
                    email: 'test@example.com',
                    password: 'wrongpassword'
                })
                .expect(401);

            expect(response.body.success).toBe(false);
        });

        it('should fail with non-existent user', async () => {
            const response = await request(app)
                .post('/api/auth/login')
                .send({
                    email: 'nonexistent@example.com',
                    password: 'password123'
                })
                .expect(401);

            expect(response.body.success).toBe(false);
        });
    });

    describe('GET /api/auth/profile', () => {
        let token;

        beforeEach(async () => {
            const response = await request(app)
                .post('/api/auth/register')
                .send({
                    name: 'Profile User',
                    email: 'profile@example.com',
                    password: 'password123'
                });

            token = response.body.data.token;
        });

        it('should get user profile with valid token', async () => {
            const response = await request(app)
                .get('/api/auth/profile')
                .set('Authorization', `Bearer ${token}`)
                .expect(200);

            expect(response.body.success).toBe(true);
            expect(response.body.data).toHaveProperty('email', 'profile@example.com');
        });

        it('should fail without token', async () => {
            await request(app)
                .get('/api/auth/profile')
                .expect(401);
        });

        it('should fail with invalid token', async () => {
            await request(app)
                .get('/api/auth/profile')
                .set('Authorization', 'Bearer invalid-token')
                .expect(401);
        });
    });
});

Step 4: Task Tests

Create task tests (tests/tasks.test.js):

const request = require('supertest');
const app = require('../src/app');

require('./setup');

describe('Task Endpoints', () => {
    let token;
    let userId;

    beforeEach(async () => {
        // Register and login user
        const response = await request(app)
            .post('/api/auth/register')
            .send({
                name: 'Task User',
                email: 'taskuser@example.com',
                password: 'password123'
            });

        token = response.body.data.token;
        userId = response.body.data.user._id;
    });

    describe('POST /api/tasks', () => {
        it('should create a new task', async () => {
            const response = await request(app)
                .post('/api/tasks')
                .set('Authorization', `Bearer ${token}`)
                .send({
                    title: 'Test Task',
                    description: 'This is a test task',
                    status: 'pending',
                    priority: 'high'
                })
                .expect(201);

            expect(response.body.success).toBe(true);
            expect(response.body.data).toHaveProperty('title', 'Test Task');
            expect(response.body.data).toHaveProperty('userId', userId);
        });

        it('should fail without authentication', async () => {
            await request(app)
                .post('/api/tasks')
                .send({
                    title: 'Test Task'
                })
                .expect(401);
        });

        it('should fail with invalid data', async () => {
            const response = await request(app)
                .post('/api/tasks')
                .set('Authorization', `Bearer ${token}`)
                .send({
                    title: 'ab' // Too short
                })
                .expect(400);

            expect(response.body.success).toBe(false);
        });
    });

    describe('GET /api/tasks', () => {
        beforeEach(async () => {
            // Create test tasks
            await request(app)
                .post('/api/tasks')
                .set('Authorization', `Bearer ${token}`)
                .send({
                    title: 'Task 1',
                    status: 'pending',
                    priority: 'high'
                });

            await request(app)
                .post('/api/tasks')
                .set('Authorization', `Bearer ${token}`)
                .send({
                    title: 'Task 2',
                    status: 'completed',
                    priority: 'low'
                });
        });

        it('should get all tasks', async () => {
            const response = await request(app)
                .get('/api/tasks')
                .set('Authorization', `Bearer ${token}`)
                .expect(200);

            expect(response.body.success).toBe(true);
            expect(response.body.data.tasks).toHaveLength(2);
            expect(response.body.data).toHaveProperty('pagination');
        });

        it('should filter tasks by status', async () => {
            const response = await request(app)
                .get('/api/tasks?status=pending')
                .set('Authorization', `Bearer ${token}`)
                .expect(200);

            expect(response.body.data.tasks).toHaveLength(1);
            expect(response.body.data.tasks[0]).toHaveProperty('status', 'pending');
        });

        it('should paginate tasks', async () => {
            const response = await request(app)
                .get('/api/tasks?page=1&limit=1')
                .set('Authorization', `Bearer ${token}`)
                .expect(200);

            expect(response.body.data.tasks).toHaveLength(1);
            expect(response.body.data.pagination).toHaveProperty('totalPages', 2);
        });
    });

    describe('GET /api/tasks/statistics', () => {
        it('should get task statistics', async () => {
            await request(app)
                .post('/api/tasks')
                .set('Authorization', `Bearer ${token}`)
                .send({
                    title: 'Test Task',
                    status: 'pending'
                });

            const response = await request(app)
                .get('/api/tasks/statistics')
                .set('Authorization', `Bearer ${token}`)
                .expect(200);

            expect(response.body.success).toBe(true);
            expect(response.body.data).toHaveProperty('total', 1);
            expect(response.body.data).toHaveProperty('byStatus');
            expect(response.body.data.byStatus).toHaveProperty('pending', 1);
        });
    });
});
Tip: Use mongodb-memory-server to run tests in an isolated in-memory database. This ensures tests don't affect your development or production database.

Step 5: API Documentation with Swagger

Install Swagger dependencies:

npm install swagger-jsdoc swagger-ui-express

Create Swagger configuration (src/config/swagger.js):

const swaggerJsdoc = require('swagger-jsdoc');

const options = {
    definition: {
        openapi: '3.0.0',
        info: {
            title: 'Task Management API',
            version: '1.0.0',
            description: 'A comprehensive task management REST API with authentication, categories, and file attachments',
            contact: {
                name: 'API Support',
                email: 'support@taskmanager.com'
            },
        },
        servers: [
            {
                url: 'http://localhost:5000',
                description: 'Development server'
            }
        ],
        components: {
            securitySchemes: {
                bearerAuth: {
                    type: 'http',
                    scheme: 'bearer',
                    bearerFormat: 'JWT'
                }
            },
            schemas: {
                User: {
                    type: 'object',
                    properties: {
                        _id: { type: 'string' },
                        name: { type: 'string' },
                        email: { type: 'string', format: 'email' },
                        role: { type: 'string', enum: ['user', 'admin'] },
                        isActive: { type: 'boolean' },
                        createdAt: { type: 'string', format: 'date-time' }
                    }
                },
                Task: {
                    type: 'object',
                    properties: {
                        _id: { type: 'string' },
                        title: { type: 'string' },
                        description: { type: 'string' },
                        status: {
                            type: 'string',
                            enum: ['pending', 'in-progress', 'completed', 'cancelled']
                        },
                        priority: {
                            type: 'string',
                            enum: ['low', 'medium', 'high', 'urgent']
                        },
                        dueDate: { type: 'string', format: 'date-time' },
                        categoryId: { type: 'string' },
                        tags: { type: 'array', items: { type: 'string' } },
                        userId: { type: 'string' },
                        createdAt: { type: 'string', format: 'date-time' }
                    }
                },
                Error: {
                    type: 'object',
                    properties: {
                        success: { type: 'boolean', example: false },
                        message: { type: 'string' }
                    }
                }
            }
        }
    },
    apis: ['./src/routes/*.js']
};

const specs = swaggerJsdoc(options);

module.exports = specs;

Add Swagger to app.js:

const swaggerUi = require('swagger-ui-express');
const swaggerSpecs = require('./config/swagger');

// API Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs));

Add Swagger annotations to routes (src/routes/auth.js):

/**
 * @swagger
 * /api/auth/register:
 *   post:
 *     summary: Register a new user
 *     tags: [Authentication]
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required:
 *               - name
 *               - email
 *               - password
 *             properties:
 *               name:
 *                 type: string
 *                 example: John Doe
 *               email:
 *                 type: string
 *                 format: email
 *                 example: john@example.com
 *               password:
 *                 type: string
 *                 format: password
 *                 minLength: 6
 *                 example: password123
 *     responses:
 *       201:
 *         description: User registered successfully
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 data:
 *                   type: object
 *                   properties:
 *                     user:
 *                       $ref: '#/components/schemas/User'
 *                     token:
 *                       type: string
 *       400:
 *         description: Validation error
 *       409:
 *         description: User already exists
 */
router.post('/register', registerValidation, validate, authController.register);

Step 6: Rate Limiting

Install rate limiting middleware:

npm install express-rate-limit

Create rate limiter middleware (src/middleware/rateLimiter.js):

const rateLimit = require('express-rate-limit');

// General API limiter
const apiLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // Limit each IP to 100 requests per windowMs
    message: 'Too many requests from this IP, please try again later'
});

// Strict limiter for authentication routes
const authLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5, // 5 attempts per 15 minutes
    skipSuccessfulRequests: true,
    message: 'Too many authentication attempts, please try again later'
});

// File upload limiter
const uploadLimiter = rateLimit({
    windowMs: 60 * 60 * 1000, // 1 hour
    max: 10, // 10 uploads per hour
    message: 'Too many file uploads, please try again later'
});

module.exports = { apiLimiter, authLimiter, uploadLimiter };

Apply rate limiters in app.js:

const { apiLimiter, authLimiter } = require('./middleware/rateLimiter');

// Apply rate limiting
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);

Step 7: Request Logging

Create custom request logger (src/middleware/requestLogger.js):

const fs = require('fs');
const path = require('path');

// Create logs directory if it doesn't exist
const logsDir = path.join(__dirname, '../../logs');
if (!fs.existsSync(logsDir)) {
    fs.mkdirSync(logsDir);
}

const requestLogger = (req, res, next) => {
    const start = Date.now();

    // Log response
    res.on('finish', () => {
        const duration = Date.now() - start;
        const log = {
            timestamp: new Date().toISOString(),
            method: req.method,
            url: req.url,
            status: res.statusCode,
            duration: `${duration}ms`,
            ip: req.ip,
            userAgent: req.get('user-agent')
        };

        // Log to file
        const logFile = path.join(logsDir, `${new Date().toISOString().split('T')[0]}.log`);
        fs.appendFileSync(logFile, JSON.stringify(log) + '\n');

        // Log errors to console
        if (res.statusCode >= 400) {
            console.error('Error:', log);
        }
    });

    next();
};

module.exports = requestLogger;
Note: In production, consider using a logging service like Winston or Bunyan for more advanced logging capabilities.

Step 8: Input Sanitization

Install sanitization packages:

npm install express-mongo-sanitize xss-clean

Add sanitization middleware to app.js:

const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');

// Data sanitization against NoSQL injection
app.use(mongoSanitize());

// Data sanitization against XSS
app.use(xss());

Step 9: Environment-Based Configuration

Create configuration file (src/config/config.js):

module.exports = {
    env: process.env.NODE_ENV || 'development',
    port: process.env.PORT || 5000,
    mongoUri: process.env.MONGODB_URI,
    jwtSecret: process.env.JWT_SECRET,
    jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
    bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS) || 10,
    maxFileSize: parseInt(process.env.MAX_FILE_SIZE) || 5242880,
    allowedFileTypes: process.env.ALLOWED_FILE_TYPES?.split(',') || [
        'image/jpeg',
        'image/png',
        'application/pdf'
    ],
    corsOrigin: process.env.CORS_ORIGIN || '*',
    logLevel: process.env.LOG_LEVEL || 'info'
};

Step 10: Production Deployment Checklist

Create a comprehensive deployment checklist:

# Production Environment Variables
NODE_ENV=production
PORT=5000
MONGODB_URI=mongodb://production-server/taskmanagement
JWT_SECRET=your-production-secret-key-here
JWT_EXPIRES_IN=7d
BCRYPT_ROUNDS=12
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,application/pdf
CORS_ORIGIN=https://yourdomain.com

# Production Checklist:
# 1. Set strong JWT_SECRET
# 2. Use production MongoDB server
# 3. Enable HTTPS
# 4. Configure CORS for specific domain
# 5. Set up process manager (PM2)
# 6. Configure reverse proxy (Nginx)
# 7. Set up monitoring and logging
# 8. Enable backups
# 9. Configure firewall rules
# 10. Set up SSL certificates

Install PM2 for process management:

npm install -g pm2

# Start application
pm2 start server.js --name task-api

# Monitor application
pm2 monit

# View logs
pm2 logs task-api

# Restart application
pm2 restart task-api

# Save PM2 configuration
pm2 save

# Set PM2 to start on system boot
pm2 startup
Warning: Never commit sensitive information like JWT secrets or database credentials to version control. Always use environment variables for configuration.

Practice Exercise

Complete the following tasks:

  1. Run all tests and ensure they pass: npm test
  2. Check test coverage: npm run test:coverage
  3. Access API documentation at http://localhost:5000/api-docs
  4. Test rate limiting by making multiple rapid requests
  5. Add Swagger documentation for task routes
  6. Test input sanitization with malicious payloads
  7. Review security headers using browser developer tools
  8. Set up PM2 and test process restart

Security Best Practices Summary

Our API now implements comprehensive security measures:

  • Authentication: JWT-based authentication with bcrypt password hashing
  • Authorization: Role-based access control and user ownership validation
  • Rate Limiting: Protection against brute force and DoS attacks
  • Input Validation: express-validator for all user inputs
  • Input Sanitization: Protection against NoSQL injection and XSS
  • Security Headers: Helmet middleware for HTTP security headers
  • CORS: Configurable cross-origin resource sharing
  • File Upload Security: File type and size validation
  • Error Handling: No sensitive data exposure in error messages
  • Logging: Comprehensive request and error logging

Summary

In this final lesson, we completed our REST API project with:

  • Comprehensive automated testing with Jest and Supertest
  • In-memory database testing with mongodb-memory-server
  • Interactive API documentation with Swagger/OpenAPI
  • Rate limiting for DDoS and brute force protection
  • Request logging for monitoring and debugging
  • Input sanitization against injection attacks
  • Environment-based configuration management
  • Production deployment checklist and PM2 setup
  • Comprehensive security hardening

You now have a production-ready REST API with authentication, CRUD operations, file uploads, pagination, filtering, testing, documentation, and security best practices. This project demonstrates professional-level API development skills that are highly valued in the industry.