Lesson 37-Express Route Handling and Route Controllers

Route Handling

Basic Routes

The simplest route definition includes an HTTP method, a path, and a handler function.

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.send('Hello World!');
});

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});

In this example, when a client sends a GET request to the root path (/), the server responds with “Hello World!”.

Dynamic Routes

Dynamic routes allow URLs to include variables that can be passed as parameters to the handler function.

app.get('/user/:id', (req, res) => {
    const userId = req.params.id;
    res.send(`User ID: ${userId}`);
});

Here, :id is a dynamic parameter accessible via req.params.id.

Route Parameters

In addition to dynamic route parameters, you can define additional parameters in the route handler function.

app.get('/search', (req, res) => {
    const query = req.query.q;
    res.send(`Search query: ${query}`);
});

In this example, the req.query object contains all key-value pairs from the query string.

Route Groups

Route groups allow you to share a common prefix across a set of routes, which is useful for handling similar resources.

app.use('/api', (req, res, next) => {
    console.log('API route accessed');
    next();
});

app.get('/api/users', (req, res) => {
    res.send('List of users');
});

app.get('/api/posts', (req, res) => {
    res.send('List of posts');
});

Here, /api is a route group, and all routes starting with /api pass through this middleware.

Error Handling

Error-handling middleware is a critical part of the Express routing system, used to catch and handle uncaught errors.

app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).send('Something broke!');
});

This middleware should be placed after all other routes to ensure it catches any errors.

Route Hierarchy

Express supports route hierarchies, allowing one route handler to call another route.

const router = express.Router();

router.get('/user/:id', (req, res) => {
    const userId = req.params.id;
    res.send(`User ID: ${userId}`);
});

app.use('/api', router);

Here, router is a sub-router that can be used independently of the main application.

Route Redirection

Routes can be configured to redirect to another URL.

app.get('/old-page', (req, res) => {
    res.redirect(301, '/new-page');
});

This redirects all requests to /old-page to /new-page with a 301 status code.

Route Restrictions

Middleware can restrict access to certain routes, such as based on user permissions.

function adminOnly(req, res, next) {
    if (req.user.isAdmin) {
        next();
    } else {
        res.status(403).send('Forbidden');
    }
}

app.get('/admin', adminOnly, (req, res) => {
    res.send('Admin panel');
});

In this example, only admin users can access the /admin path.

Route Caching

For static content or frequently accessed resources, caching can significantly improve performance.

const cache = require('node-cache');

const dataCache = new cache({ stdTTL: 60 });

app.get('/data', (req, res) => {
    const cachedData = dataCache.get('data');
    if (cachedData) {
        res.send(cachedData);
    } else {
        // Fetch data from database or other source
        const data = fetchData();
        dataCache.set('data', data);
        res.send(data);
    }
});

Route Testing

Using testing frameworks (e.g., Mocha or Jest) and request libraries (e.g., supertest) makes it easy to test route behavior.

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

describe('GET /', () => {
    test('should respond with Hello World!', async () => {
        const response = await request(app).get('/');
        expect(response.text).toBe('Hello World!');
    });
});

Route Parameters and Dynamic Routes

In Express, dynamic routes allow you to define URL paths with parameters that are populated with specific values provided by the client at runtime. Dynamic routes are powerful for building RESTful APIs and handling various URL structures.

Dynamic Route Basics

The basic form of a dynamic route uses a colon (:) to mark parameters in the route path. For example, you can define a route to fetch user profiles:

app.get('/users/:id', function(req, res) {
    res.send('User ID is ' + req.params.id);
});

In this example, :id is a dynamic parameter, and a client can access this route by providing a specific ID in the URL. For instance, a request to http://localhost:3000/users/123 returns "User ID is 123".

Accessing Route Parameters

Dynamic route parameters are accessible via the req.params object, where the keys are the parameter names and the values are the specific values provided by the client.

app.get('/users/:id', function(req, res) {
    const userId = req.params.id;
    // Query database or other data store to fetch user info
    getUserInfo(userId, function(userInfo) {
        res.json(userInfo);
    });
});

Multiple Dynamic Parameters

You can define multiple dynamic parameters in the same route path:

app.get('/users/:id/posts/:postId', function(req, res) {
    const userId = req.params.id;
    const postId = req.params.postId;
    // Fetch a specific post for a specific user from the database
    getPostById(userId, postId, function(post) {
        res.json(post);
    });
});

Validating Route Parameters

To ensure client-provided parameters meet expectations, you can use middleware to validate them. For example, you can create middleware to check if :id is a valid integer:

function validateUserId(req, res, next) {
    const userId = req.params.id;
    if (!/^\d+$/.test(userId)) {
        res.status(400).send('Invalid user ID');
        return;
    }
    next();
}

app.get('/users/:id', validateUserId, function(req, res) {
    const userId = req.params.id;
    res.send('Valid user ID: ' + userId);
});

Default Values for Route Parameters

To provide default values for dynamic parameters, you can use regular expressions and callback functions:

app.get('/users/:id([0-9]+)?', function(req, res) {
    const userId = req.params.id || 'default';
    res.send('User ID is ' + userId);
});

In this example, if the client does not provide the :id parameter, it defaults to 'default'.

Optional Route Parameters

To make parameters optional, use regular expressions:

app.get('/users/:id?', function(req, res) {
    const userId = req.params.id || 'no-id';
    res.send('User ID is ' + userId);
});

Order of Route Parameters

When defining multiple dynamic parameters, their order matters, as Express populates the req.params object based on the order of appearance:

app.get('/users/:id/posts/:postId', function(req, res) {
    console.log(req.params); // { id: '123', postId: '456' }
});

Route Parameter Namespaces

To avoid naming conflicts, use distinct namespaces for parameters, such as :userId and :postId instead of using :id for both.

Route Parameters and RESTful Design

Dynamic route parameters are central to RESTful API design, enabling clear and semantic URLs that clients can intuitively understand.

Route Grouping and Named Routes

Route Grouping

Route grouping allows you to apply a common prefix to a set of routes, reducing repetitive path prefixes and aiding in modularization and code organization.

Example Code

Suppose your application has multiple user-related API endpoints. You can use route grouping to organize these endpoints:

const express = require('express');
const app = express();
const userRouter = express.Router();

// User-related routes
userRouter.get('/', (req, res) => {
    res.send('List all users');
});

userRouter.get('/:id', (req, res) => {
    res.send(`Get user with ID ${req.params.id}`);
});

userRouter.post('/', (req, res) => {
    res.send('Create a new user');
});

// Apply route group
app.use('/api/users', userRouter);

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});

In this example, all requests starting with /api/users are handled by userRouter, allowing you to define user-related routes without repeating /api/users.

Named Routes

Named routes allow you to assign readable names to routes, which is useful for debugging and maintaining code. While Express does not natively support named routes, you can achieve similar functionality with some techniques.

Implementing Named Routes

You can create a route mapping object to implement named routes, allowing you to reference and manipulate routes by name.

const express = require('express');
const app = express();
const routes = {};

// Define named routes
routes.usersList = app.get('/api/users', (req, res) => {
    res.send('List all users');
});

routes.userDetails = app.get('/api/users/:id', (req, res) => {
    res.send(`Get user with ID ${req.params.id}`);
});

// Use named routes
console.log(routes.usersList.path); // Outputs: /api/users
console.log(routes.userDetails.path); // Outputs: /api/users/:id

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});

Combining Route Grouping and Named Routes

Combining route grouping with named routes makes code cleaner and easier to maintain. Here’s an example:

const express = require('express');
const app = express();
const userRouter = express.Router();
const routes = {};

// Define named routes within a route group
routes.usersList = userRouter.get('/', (req, res) => {
    res.send('List all users');
});

routes.userDetails = userRouter.get('/:id', (req, res) => {
    res.send(`Get user with ID ${req.params.id}`);
});

// Apply route group
app.use('/api/users', userRouter);

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});

Using Named Routes for Testing

Named routes are particularly useful during testing, as they allow you to reference routes by name instead of hardcoding paths, improving test code readability and maintainability.

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

describe('User Routes', () => {
    it('should list all users', (done) => {
        request(app)
            .get(routes.usersList.path)
            .expect(200, 'List all users', done);
    });

    it('should get user details', (done) => {
        request(app)
            .get(routes.userDetails.path.replace(':id', '1'))
            .expect(200, 'Get user with ID 1', done);
    });
});

Using Regular Expressions for Route Matching

Regular Expression Basics

Before diving in, let’s review key regular expression concepts:

  • Character Classes: [abc] matches a, b, or c.
  • Ranges: [a-z] matches lowercase letters from a to z.
  • Any Character: . matches any single character.
  • Repetition: * (zero or more), + (one or more), ? (zero or one).
  • Grouping: () groups patterns, applying repetition or options to the group.
  • Non-Capturing Groups: (?:) groups without capturing the match.
  • Lookahead: (?=...) requires the pattern to be followed by ..., without capturing ....
  • Negative Lookbehind: (?<!...) requires the pattern not to be preceded by ....

Using Regular Expressions for Route Matching

In Express, you can use regular expressions directly in route paths to define matching rules. Regular expressions should be enclosed in parentheses () and can include named capture groups for accessing matched parameters in req.params.

Example Code

Suppose you want a route that only accepts numeric parameters:

const express = require('express');
const app = express();

// Match numeric parameters with a regular expression
app.get('/user/:id([0-9]+)', (req, res) => {
    const userId = req.params.id;
    res.send(`User ID: ${userId}`);
});

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});

In this example, ([0-9]+) is a regular expression that matches one or more digits. A request to /user/123 sets req.params.id to 123.

Complex Regular Expression Matching

Regular expressions can handle more complex route matching logic. For example, you can create a route that only accepts alphanumeric usernames:

app.get('/user/:username([a-zA-Z0-9]+)', (req, res) => {
    const username = req.params.username;
    res.send(`Username: ${username}`);
});

Or a route that only accepts email addresses starting with @:

app.get('/email/@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', (req, res) => {
    const email = req.params['0'];
    res.send(`Email: ${email}`);
});

Optional Parameters

Regular expressions can define optional route parameters. For example, the following route matches /product/123 and /product/123/review:

app.get('/product/:id([0-9]+)/review?', (req, res) => {
    const productId = req.params.id;
    const hasReview = req.params.review !== undefined;
    res.send(`Product ID: ${productId}, Has Review: ${hasReview}`);
});

Multi-Condition Matching

Use logical operators (e.g., |) in regular expressions to match multiple conditions. For example, the following route matches /user/123 or /user/john:

app.get('/user/:id([0-9]+|john)', (req, res) => {
    const userId = req.params.id;
    res.send(`User ID: ${userId}`);
});

Route Controllers

What Are Route Controllers?

A route controller is a design pattern where each route handler function is encapsulated in a separate controller function. This centralizes the business logic for each route, making the code clearer and easier to maintain and extend.

Creating Controller Files

First, create a controller file for each route or group of related routes. For example, for user-related requests, create a usersController.js file:

// controllers/usersController.js
module.exports = {
    getUsers: (req, res) => {
        // Fetch user list from database
        const users = [
            { id: 1, name: 'John Doe' },
            { id: 2, name: 'Jane Smith' }
        ];
        res.json(users);
    },

    getUserById: (req, res) => {
        const userId = req.params.id;
        // Fetch user by ID from database
        const user = { id: userId, name: `User ${userId}` };
        res.json(user);
    },

    createUser: (req, res) => {
        const newUser = req.body;
        // Save new user to database
        res.status(201).json(newUser);
    }
};

Using Controllers in Routes

In your main application file, import the controller and associate it with the corresponding routes:

// app.js
const express = require('express');
const app = express();
const usersController = require('./controllers/usersController');

app.get('/users', usersController.getUsers);
app.get('/users/:id', usersController.getUserById);
app.post('/users', usersController.createUser);

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});

Advanced Controller Usage

Middleware Integration: You can use middleware in controller functions, such as for authentication or logging.

const authenticate = (req, res, next) => {
    // Authentication logic
    next();
};

app.get('/users/:id', authenticate, usersController.getUserById);

Error Handling: Controller functions can throw errors, which are caught by Express’s error-handling middleware.

usersController.getUserById = (req, res, next) => {
    const userId = req.params.id;
    if (!userId) {
        return next(new Error('User ID is required'));
    }
    // Other logic...
};

Controller Interaction with Models

In real applications, controllers typically interact with a model layer to handle data storage and retrieval. For example, replace hardcoded data with database operations:

// controllers/usersController.js
const User = require('../models/User');

module.exports = {
    getUsers: async (req, res) => {
        try {
            const users = await User.find();
            res.json(users);
        } catch (error) {
            res.status(500).json({ message: error.message });
        }
    },
    // Other controller methods...
};

Controllers and Service Layer

In complex projects, you may introduce a service layer to further abstract business logic, decoupling it from the data access layer.

// services/userService.js
const User = require('../models/User');

module.exports = {
    getUsers: async () => {
        return await User.find();
    }
};

// controllers/usersController.js
const userService = require('../services/userService');

module.exports = {
    getUsers: async (req, res) => {
        try {
            const users = await userService.getUsers();
            res.json(users);
        } catch (error) {
            res.status(500).json({ message: error.message });
        }
    }
};

Testing Controllers

Since controller functions are typically independent, they are well-suited for unit testing. Use testing frameworks like Mocha or Jest to write test cases.

// tests/usersController.test.js
const usersController = require('../controllers/usersController');
const userService = require('../services/userService');

describe('Users Controller', () => {
    beforeEach(() => {
        jest.spyOn(userService, 'getUsers').mockResolvedValue([{ id: 1, name: 'Test User' }]);
    });

    afterEach(() => {
        userService.getUsers.mockRestore();
    });

    it('should return a list of users', async () => {
        const req = {};
        const res = {
            json: jest.fn()
        };

        await usersController.getUsers(req, res);

        expect(res.json).toHaveBeenCalledWith([{ id: 1, name: 'Test User' }]);
    });
});

Project Architecture

File and Directory Structure

A good starting point is designing a logical file and directory structure. Below is a typical directory structure for an Express application:

my-app/
|-- server.js
|-- package.json
|-- controllers/
|   |-- index.js
|   |-- usersController.js
|-- models/
|   |-- index.js
|   |-- userModel.js
|-- routers/
|   |-- index.js
|   |-- usersRouter.js
|-- middleware/
|   |-- index.js
|   |-- authMiddleware.js
|-- views/
|   |-- layout.ejs
|   |-- index.ejs
|-- public/
|   |-- css/
|   |-- js/
|-- test/
|   |-- index.js
  • server.js: Main application entry point, responsible for starting the server and loading configurations.
  • controllers/: Directory for route controllers.
  • models/: Directory for data models, typically interacting with a database.
  • routers/: Directory for route modules.
  • middleware/: Directory for middleware.
  • views/: Directory for view templates.
  • public/: Directory for static files.
  • test/: Directory for test files.

Separating Routes and Controllers

Separating routes and controllers is a key step in modularization. Each controller file should focus on specific business logic, while route files map requests to the appropriate controller functions.

routers/usersRouter.js

const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');

router.get('/', usersController.getUsers);
router.get('/:id', usersController.getUserById);
router.post('/', usersController.createUser);

module.exports = router;

controllers/usersController.js

module.exports = {
    getUsers: (req, res) => {
        // Logic handling
    },
    getUserById: (req, res) => {
        // Logic handling
    },
    createUser: (req, res) => {
        // Logic handling
    }
};

Data Models and Service Layer

Data models and related service layers should be separated from controllers to improve modularization and code reuse.

models/userModel.js

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
    name: String,
    email: String
});

module.exports = mongoose.model('User', UserSchema);

services/userService.js

const User = require('../models/userModel');

module.exports = {
    getUsers: async () => {
        return await User.find();
    },
    getUserById: async (id) => {
        return await User.findById(id);
    },
    createUser: async (user) => {
        const newUser = new User(user);
        return await newUser.save();
    }
};

Modularizing Middleware

Middleware can also be modularized, especially for reusable functionality like logging or authentication.

middleware/authMiddleware.js

module.exports = (req, res, next) => {
    // Authentication logic
    next();
};

Main Application Entry Point

In the main application entry file, import and use the above modules.

server.js

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const usersRouter = require('./routers/usersRouter');
const authMiddleware = require('./middleware/authMiddleware');

app.use(bodyParser.json());
app.use(authMiddleware);
app.use('/api/users', usersRouter);

app.listen(3000, () => {
    console.log('Server listening on port 3000');
});
Share your love