Node.js & Express

Building a REST API Project - Part 1

35 min Lesson 36 of 40

Building a Complete REST API Project - Part 1

In this lesson, we'll start building a complete Task Management REST API from scratch. This project will incorporate all the concepts we've learned throughout the course, including authentication, database operations, validation, error handling, and security best practices.

Project Overview

We'll build a Task Management API with the following features:

  • User registration and authentication (JWT)
  • Create, read, update, and delete tasks
  • Task categories and tags
  • Task filtering and pagination
  • File attachments for tasks
  • User profile management
  • Role-based access control
  • API documentation
Note: This is a three-part lesson series. In Part 1, we'll set up the project structure, configure the database, create models, and implement authentication endpoints.

Step 1: Project Setup

First, let's create and initialize our project:

# Create project directory
mkdir task-management-api
cd task-management-api

# Initialize npm project
npm init -y

# Install core dependencies
npm install express mongoose dotenv bcryptjs jsonwebtoken
npm install express-validator express-async-errors
npm install cors helmet compression morgan

# Install development dependencies
npm install --save-dev nodemon

Step 2: Folder Structure

Create a well-organized folder structure for our project:

task-management-api/
├── src/
│   ├── config/
│   │   └── database.js
│   ├── models/
│   │   ├── User.js
│   │   ├── Task.js
│   │   └── Category.js
│   ├── controllers/
│   │   ├── authController.js
│   │   ├── taskController.js
│   │   ├── categoryController.js
│   │   └── userController.js
│   ├── middleware/
│   │   ├── auth.js
│   │   ├── errorHandler.js
│   │   ├── validation.js
│   │   └── upload.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── tasks.js
│   │   ├── categories.js
│   │   └── users.js
│   ├── utils/
│   │   ├── ApiResponse.js
│   │   └── ApiError.js
│   └── app.js
├── uploads/
├── .env
├── .gitignore
├── package.json
└── server.js

Create the folder structure:

mkdir -p src/{config,models,controllers,middleware,routes,utils}
mkdir uploads

Step 3: Environment Configuration

Create a .env file for environment variables:

# .env
NODE_ENV=development
PORT=5000

# Database
MONGODB_URI=mongodb://localhost:27017/task-management

# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d

# Security
BCRYPT_ROUNDS=10

# File Upload
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,application/pdf
Warning: Never commit your .env file to version control. Add it to .gitignore immediately.

Create a .gitignore file:

node_modules/
.env
uploads/
*.log
.DS_Store

Step 4: Database Configuration

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

const mongoose = require('mongoose');

const connectDB = async () => {
    try {
        const conn = await mongoose.connect(process.env.MONGODB_URI, {
            useNewUrlParser: true,
            useUnifiedTopology: true
        });

        console.log(`MongoDB Connected: ${conn.connection.host}`);

        // Handle connection events
        mongoose.connection.on('error', (err) => {
            console.error('MongoDB connection error:', err);
        });

        mongoose.connection.on('disconnected', () => {
            console.log('MongoDB disconnected');
        });

        // Graceful shutdown
        process.on('SIGINT', async () => {
            await mongoose.connection.close();
            console.log('MongoDB connection closed through app termination');
            process.exit(0);
        });

    } catch (error) {
        console.error('Error connecting to MongoDB:', error.message);
        process.exit(1);
    }
};

module.exports = connectDB;

Step 5: Utility Classes

Create utility classes for API responses and errors (src/utils/ApiResponse.js):

class ApiResponse {
    constructor(statusCode, data, message = 'Success') {
        this.statusCode = statusCode;
        this.success = statusCode < 400;
        this.message = message;
        this.data = data;
    }

    static success(data, message = 'Success', statusCode = 200) {
        return new ApiResponse(statusCode, data, message);
    }

    static created(data, message = 'Resource created successfully') {
        return new ApiResponse(201, data, message);
    }
}

module.exports = ApiResponse;

Create API error class (src/utils/ApiError.js):

class ApiError extends Error {
    constructor(statusCode, message, isOperational = true, stack = '') {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = isOperational;
        this.success = false;

        if (stack) {
            this.stack = stack;
        } else {
            Error.captureStackTrace(this, this.constructor);
        }
    }

    static badRequest(message = 'Bad Request') {
        return new ApiError(400, message);
    }

    static unauthorized(message = 'Unauthorized') {
        return new ApiError(401, message);
    }

    static forbidden(message = 'Forbidden') {
        return new ApiError(403, message);
    }

    static notFound(message = 'Resource not found') {
        return new ApiError(404, message);
    }

    static conflict(message = 'Conflict') {
        return new ApiError(409, message);
    }

    static internal(message = 'Internal Server Error') {
        return new ApiError(500, message, false);
    }
}

module.exports = ApiError;

Step 6: User Model

Create the User model (src/models/User.js):

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');

const userSchema = new mongoose.Schema({
    name: {
        type: String,
        required: [true, 'Name is required'],
        trim: true,
        minlength: [2, 'Name must be at least 2 characters'],
        maxlength: [50, 'Name cannot exceed 50 characters']
    },
    email: {
        type: String,
        required: [true, 'Email is required'],
        unique: true,
        lowercase: true,
        trim: true,
        match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email']
    },
    password: {
        type: String,
        required: [true, 'Password is required'],
        minlength: [6, 'Password must be at least 6 characters'],
        select: false // Don't include password in queries by default
    },
    role: {
        type: String,
        enum: ['user', 'admin'],
        default: 'user'
    },
    avatar: {
        type: String,
        default: null
    },
    isActive: {
        type: Boolean,
        default: true
    },
    refreshTokens: [{
        token: String,
        createdAt: {
            type: Date,
            default: Date.now,
            expires: 604800 // 7 days in seconds
        }
    }]
}, {
    timestamps: true
});

// Hash password before saving
userSchema.pre('save', async function(next) {
    if (!this.isModified('password')) return next();

    const rounds = parseInt(process.env.BCRYPT_ROUNDS) || 10;
    this.password = await bcrypt.hash(this.password, rounds);
    next();
});

// Method to compare password
userSchema.methods.comparePassword = async function(candidatePassword) {
    return await bcrypt.compare(candidatePassword, this.password);
};

// Method to generate JWT token
userSchema.methods.generateAuthToken = function() {
    const token = jwt.sign(
        {
            id: this._id,
            email: this.email,
            role: this.role
        },
        process.env.JWT_SECRET,
        { expiresIn: process.env.JWT_EXPIRES_IN }
    );
    return token;
};

// Method to get public profile
userSchema.methods.toJSON = function() {
    const user = this.toObject();
    delete user.password;
    delete user.refreshTokens;
    delete user.__v;
    return user;
};

// Static method to find user by credentials
userSchema.statics.findByCredentials = async function(email, password) {
    const user = await this.findOne({ email, isActive: true }).select('+password');

    if (!user) {
        throw new Error('Invalid email or password');
    }

    const isMatch = await user.comparePassword(password);

    if (!isMatch) {
        throw new Error('Invalid email or password');
    }

    return user;
};

const User = mongoose.model('User', userSchema);

module.exports = User;
Tip: The select: false option on the password field prevents it from being included in query results by default. Use .select('+password') when you need to access it.

Step 7: Category Model

Create the Category model (src/models/Category.js):

const mongoose = require('mongoose');

const categorySchema = new mongoose.Schema({
    name: {
        type: String,
        required: [true, 'Category name is required'],
        trim: true,
        unique: true,
        maxlength: [30, 'Category name cannot exceed 30 characters']
    },
    description: {
        type: String,
        trim: true,
        maxlength: [200, 'Description cannot exceed 200 characters']
    },
    color: {
        type: String,
        match: [/^#[0-9A-F]{6}$/i, 'Please provide a valid hex color'],
        default: '#3498db'
    },
    icon: {
        type: String,
        default: 'folder'
    },
    userId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    }
}, {
    timestamps: true
});

// Index for faster queries
categorySchema.index({ userId: 1, name: 1 });

const Category = mongoose.model('Category', categorySchema);

module.exports = Category;

Step 8: Task Model

Create the Task model (src/models/Task.js):

const mongoose = require('mongoose');

const taskSchema = new mongoose.Schema({
    title: {
        type: String,
        required: [true, 'Task title is required'],
        trim: true,
        minlength: [3, 'Title must be at least 3 characters'],
        maxlength: [100, 'Title cannot exceed 100 characters']
    },
    description: {
        type: String,
        trim: true,
        maxlength: [1000, 'Description cannot exceed 1000 characters']
    },
    status: {
        type: String,
        enum: ['pending', 'in-progress', 'completed', 'cancelled'],
        default: 'pending'
    },
    priority: {
        type: String,
        enum: ['low', 'medium', 'high', 'urgent'],
        default: 'medium'
    },
    dueDate: {
        type: Date,
        validate: {
            validator: function(value) {
                return value >= new Date();
            },
            message: 'Due date must be in the future'
        }
    },
    categoryId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Category'
    },
    tags: [{
        type: String,
        trim: true,
        maxlength: [20, 'Tag cannot exceed 20 characters']
    }],
    attachments: [{
        filename: String,
        originalName: String,
        mimeType: String,
        size: Number,
        path: String,
        uploadedAt: {
            type: Date,
            default: Date.now
        }
    }],
    userId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    },
    completedAt: {
        type: Date
    }
}, {
    timestamps: true
});

// Indexes for better query performance
taskSchema.index({ userId: 1, status: 1 });
taskSchema.index({ userId: 1, categoryId: 1 });
taskSchema.index({ userId: 1, dueDate: 1 });
taskSchema.index({ tags: 1 });

// Virtual for checking if task is overdue
taskSchema.virtual('isOverdue').get(function() {
    return this.dueDate && this.dueDate < new Date() && this.status !== 'completed';
});

// Automatically set completedAt when status changes to completed
taskSchema.pre('save', function(next) {
    if (this.isModified('status')) {
        if (this.status === 'completed' && !this.completedAt) {
            this.completedAt = new Date();
        } else if (this.status !== 'completed') {
            this.completedAt = null;
        }
    }
    next();
});

// Enable virtuals in JSON output
taskSchema.set('toJSON', { virtuals: true });
taskSchema.set('toObject', { virtuals: true });

const Task = mongoose.model('Task', taskSchema);

module.exports = Task;

Step 9: Authentication Middleware

Create authentication middleware (src/middleware/auth.js):

const jwt = require('jsonwebtoken');
const User = require('../models/User');
const ApiError = require('../utils/ApiError');

const auth = async (req, res, next) => {
    try {
        // Get token from header
        const token = req.header('Authorization')?.replace('Bearer ', '');

        if (!token) {
            throw ApiError.unauthorized('No authentication token provided');
        }

        // Verify token
        const decoded = jwt.verify(token, process.env.JWT_SECRET);

        // Find user
        const user = await User.findById(decoded.id);

        if (!user || !user.isActive) {
            throw ApiError.unauthorized('User not found or inactive');
        }

        // Attach user to request
        req.user = user;
        req.token = token;

        next();
    } catch (error) {
        if (error.name === 'JsonWebTokenError') {
            return next(ApiError.unauthorized('Invalid token'));
        }
        if (error.name === 'TokenExpiredError') {
            return next(ApiError.unauthorized('Token has expired'));
        }
        next(error);
    }
};

// Middleware to check if user is admin
const isAdmin = (req, res, next) => {
    if (req.user.role !== 'admin') {
        return next(ApiError.forbidden('Access denied. Admin privileges required.'));
    }
    next();
};

module.exports = { auth, isAdmin };

Step 10: Authentication Controller

Create the authentication controller (src/controllers/authController.js):

const User = require('../models/User');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');

// Register new user
exports.register = async (req, res, next) => {
    try {
        const { name, email, password } = req.body;

        // Check if user already exists
        const existingUser = await User.findOne({ email });
        if (existingUser) {
            throw ApiError.conflict('User with this email already exists');
        }

        // Create user
        const user = new User({
            name,
            email,
            password
        });

        await user.save();

        // Generate token
        const token = user.generateAuthToken();

        res.status(201).json(
            ApiResponse.created(
                { user, token },
                'User registered successfully'
            )
        );
    } catch (error) {
        next(error);
    }
};

// Login user
exports.login = async (req, res, next) => {
    try {
        const { email, password } = req.body;

        // Find user by credentials
        const user = await User.findByCredentials(email, password);

        // Generate token
        const token = user.generateAuthToken();

        res.json(
            ApiResponse.success(
                { user, token },
                'Login successful'
            )
        );
    } catch (error) {
        next(ApiError.unauthorized(error.message));
    }
};

// Get current user profile
exports.getProfile = async (req, res, next) => {
    try {
        res.json(
            ApiResponse.success(
                req.user,
                'Profile retrieved successfully'
            )
        );
    } catch (error) {
        next(error);
    }
};

// Logout user
exports.logout = async (req, res, next) => {
    try {
        // In a real application, you might want to blacklist the token
        // or remove it from a refresh token list

        res.json(
            ApiResponse.success(
                null,
                'Logout successful'
            )
        );
    } catch (error) {
        next(error);
    }
};

Step 11: Authentication Routes

Create authentication routes (src/routes/auth.js):

const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const authController = require('../controllers/authController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');

// Validation rules
const registerValidation = [
    body('name')
        .trim()
        .notEmpty().withMessage('Name is required')
        .isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
    body('email')
        .trim()
        .notEmpty().withMessage('Email is required')
        .isEmail().withMessage('Please provide a valid email')
        .normalizeEmail(),
    body('password')
        .notEmpty().withMessage('Password is required')
        .isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
];

const loginValidation = [
    body('email')
        .trim()
        .notEmpty().withMessage('Email is required')
        .isEmail().withMessage('Please provide a valid email')
        .normalizeEmail(),
    body('password')
        .notEmpty().withMessage('Password is required')
];

// Routes
router.post('/register', registerValidation, validate, authController.register);
router.post('/login', loginValidation, validate, authController.login);
router.get('/profile', auth, authController.getProfile);
router.post('/logout', auth, authController.logout);

module.exports = router;

Step 12: Validation Middleware

Create validation middleware (src/middleware/validation.js):

const { validationResult } = require('express-validator');
const ApiError = require('../utils/ApiError');

exports.validate = (req, res, next) => {
    const errors = validationResult(req);

    if (!errors.isEmpty()) {
        const errorMessages = errors.array().map(err => err.msg);
        throw ApiError.badRequest(errorMessages.join(', '));
    }

    next();
};
Tip: Validation middleware should be placed after validation rules and before controller methods in your route definitions.

Practice Exercise

Complete the following tasks:

  1. Set up the project structure as described above
  2. Install all required dependencies
  3. Create all the models (User, Category, Task)
  4. Implement the authentication endpoints
  5. Test the registration and login endpoints using Postman or curl
  6. Verify that JWT tokens are generated correctly

Summary

In this lesson, we've completed the first part of building our REST API project:

  • Set up a well-organized project structure
  • Configured environment variables and database connection
  • Created utility classes for consistent API responses
  • Implemented User, Category, and Task models with validation
  • Built authentication middleware with JWT
  • Created authentication endpoints (register, login, profile, logout)
  • Added input validation using express-validator

In the next lesson, we'll continue by implementing CRUD operations for tasks and categories, adding file upload functionality, and implementing pagination and filtering.