FS-Routing for nodejs: a low config approach
Tags: javascript, express, routing, nextjs
Published: 2023-06-24 23:48:53

Welcome, fellow developers, to a journey through the fascinating world of routing. In this blog post, we'll take a practical and easy-going approach to routing for nodejs api frameworks inspired by nextjs routing. I'm using express as my base framework, however, this code can be easily adapted to use in other popular frameworks.

Whether you're a beginner or an intermediate developer, I've got you covered. Routing can be a bit tricky, but fret not! I'm here to guide you through the process step by step. Say goodbye to the hassle of manual route registration and complex configurations. With this approach, you'll learn how to create a router module that will simplify route management in Express.js. I'll even introduce you to the awesome File System (fs) module and the handy Path module (if those are new to you). By the time we're done, you'll have a fully functional router module that automagically handles route mapping, supports parameterized routes, and makes your code modular and reusable. So, are you ready to embark on this exciting journey with me? Let's simplify route handling and build some amazing web applications using Express.js together!

## Goal

Our goal is to create a router to handle the following routes 
```
GET: /home
GET: /authors
POST: /authors
GET: /books
POST: /books
GET: /books/[bookid]
POST: /books/[bookid]
```

By defining our route handlers in the following directory structure

```
./home.js
./authors/GET.js
./authors/POST.js
./books/index.js
./books/[bookid].js
```
Each of these files represents a different route and HTTP method combination that we'll handle in our application. 

## Setup
To kick off our adventure, let's bootstrap our project by following a few simple steps. Open up your command line and execute the following commands:

```sh

mkdir fsrouter
cd fsrouter
npm init
touch readme.md
touch fsrouter.js # CONTAINS MODULE CODE
npm install express
touch index.js # server code/entry point
```
With these commands, we create the necessary files and directories for our routing experiment. 

You can generate the controller files for this exercise by running following shell commands:
```sh
mkdir routes
cd routes

# ./routes
touch home.js

# ./routes
mkdir authors
cd authors
touch GET.js
touch POST.js
cd ..

# ./routes
mkdir books
cd books
touch index.js
touch "[bookid].js"
cd ..
```
Now, let's dive into the contents of these files. Within each handler module, we define the logic to handle the corresponding route. Here's an example:

```js
// handler modules that are named by HTTP methods such as GET,POST,PUT etc. mush default export a handler functtion
// authors/GET.js, authors/POST.js
module.exports = (req, res) => res.send("Response");

```
AND
```js
// handler modules that match a route path must export an object
// home.js, books/index.js, books/[bookid].js
module.exports = {
    GET: (req, res) => res.send(req.params),
    PUT: (req, res) => res.send("Response")
};
```
In this approach, we define the handlers as simple functions or objects with HTTP method-specific functions. The goal is to keep things straightforward and focus on the functionality.

Now, let's integrate our custom routing solution into our Express server. Open up the index.js file and add the following code:

```js

const express = require("express");
const fsrouter = require("./fsrouter");
const app = express();
app.use(fsrouter('./routes'));
app.listen(5001, () => {
    console.log("Server is up and running on port 5001!");
});
```
With this code in place, our server is ready to handle the routes we defined earlier. By utilizing the fsrouter middleware, we unleash the power of our custom routing solution without being constrained by traditional frameworks.


## Walkthrough of fsrouter.js

So, how does this routing actually work? Inside the fsrouter.js module, a clever mechanism traverses the routing structure we created. It dynamically matches the incoming requests to their corresponding handlers, allowing us to handle various HTTP methods and even dynamic routes.

Let's dive into the code and explore how fsrouter.js simplifies routing in Node.js applications:
1. Importing Required Modules

The first step in the fsrouter.js file is importing the necessary modules. It requires the fs and path modules from Node.js, which allow us to work with the file system and manipulate file paths effectively.

```js

const fs = require('fs');
const path = require('path');
```
These modules play a crucial role in scanning the file system and mapping routes to handlers.
2. Defining Supported HTTP Methods

Next, fsrouter.js declares an array of supported HTTP methods. These methods include 'GET', 'POST', 'DELETE', 'PATCH', and 'PUT'. By having a predefined list of methods, fsrouter.js can identify and map each route's corresponding HTTP method correctly.

```js

const httpMethods = ['GET', 'POST', 'DELETE', 'PATCH', 'PUT'];
```
3. Mapping Route Files to HTTP Methods

The getMethodFromName function is a utility function that extracts the HTTP method from a route file's name. It checks if the file name matches any of the supported HTTP methods using a regular expression. If there is a match, it returns the HTTP method; otherwise, it returns null.

```js

function getMethodFromName(filename) {
    const isNamedByMethod = httpMethods.some(name => (new RegExp(`^${name}.(t|j)s$`)).test(filename))
    if (isNamedByMethod) return filename.replace(/.(t|j)s$/, '');
    return null;
}
```
This function comes into play when scanning the file system and mapping route files to their corresponding HTTP methods.
4. Reading Controllers and Building Route Mapping

The readControllers function is the heart of fsrouter.js. It reads the controllers and builds a mapping of routes to their respective handlers.

```js

function readControllers(basePath, urlPath) {
    const fsPath = path.resolve(basePath, urlPath)
    const entities = fs.readdirSync(fsPath);
    return entities.map(entity => {
        const entitypath = path.resolve(fsPath, entity);
        const stat = fs.lstatSync(entitypath);

        if (stat.isFile()) {
            // Handle file-based routes
            const methodName = getMethodFromName(entity);
            if (methodName)
                return { [urlPath]: { [methodName]: require(entitypath) } }

            // Handle route files with multiple HTTP methods
            const { GET, POST, DELETE, PATCH, PUT } = require(entitypath);
            const entityUrlSegment = entity.replace(/.(t|j)s$/, '');
            const pathToMatch = entityUrlSegment === "index" ? urlPath : path.join(urlPath, entityUrlSegment);
            return { [pathToMatch]: { GET, POST, DELETE, PATCH, PUT } }
        } else if (stat.isDirectory()) {
            // Recursively handle subdirectories
            const mapping = readControllers(basePath, path.join(urlPath, entity))
            return mapping;
        }
    }).reduce((aggr, val) => {
        Object.entries(val).forEach(([path, handlers]) => {
            const commonHandlers = aggr[path] || {};
            aggr[path] = { ...commonHandlers, ...handlers }
        });
        return aggr;
    }, {})
}
```
This function takes in the base path and URL path, resolves the file system path, and reads the entities (files and directories) within that path. It then iterates through each entity, distinguishing between files and directories.

For files, it checks if they represent a single HTTP method or multiple methods. If it's a single method, the mapping is straightforward. If it's multiple methods, the function extracts the corresponding handlers for each HTTP method.

For directories, the function recursively handles subdirectories, building the complete route mapping structure.
5. Matching Routes and Handling Requests

The findHandler function is responsible for matching routes and retrieving the appropriate handler for a given HTTP method and path. It also extracts dynamic route params from url.

```js

function findHandler(routeMap, method, path) {
    const matchedPath = Object.keys(routeMap).find(pathDefinition => {
        const pathDefSegments = pathDefinition.split('/').map((segment) => {
            const [_, param] = segment.match(/^\[(.*)\]$/) || [];
            if (!param) return segment;
            return { paramName: param }
        });
        const pathSegments = path.split('/').filter((seg, pos) => seg || pos); // filterout 1st null segment. This is caused be leading '/' 
        if (pathDefSegments.length != pathSegments.length) return false;
        return pathDefSegments.every((definedSegment, pos) => {
            if (typeof definedSegment === "object") return definedSegment.value = pathSegments[pos]
            if (pathSegments[pos].toLowerCase() === definedSegment.toLowerCase()) return true;
        })
    })
    if (!matchedPath) return null;
    return {
        handler: routeMap[matchedPath][method], params: pathDefSegments.reduce((aggr, seg) => {
            if (typeof seg === "string") return aggr;
            return { ...aggr, [seg.paramName]: seg.value }
        }, {})
    };
}
```
This function takes a routing map, an HTTP method, and a URL path as input. It searches for a matching route in the routing map based on the method and path. If a match is found, it returns an object containing the matched handler function and any URL parameters extracted from the path.

6. Initialize the router
```js
function loadRoutesFrom(routesControllerDir) {
    const routeMap = readControllers(routesControllerDir, './');
    return (method, path) => findHandler(routeMap, method, path);
}
```
This function takes the path to routes directory as input. It calls readControllers to generate the routing map based on the directory structure. It then returns a function that takes an HTTP method and a URL path as arguments and calls findHandler to find the corresponding handler function and URL parameters.

7. Creating the fsrouter Middleware

Finally, fsrouter.js exports the loadRoutesFrom function, which creates the fsrouter middleware.

```js

module.exports = routesControllerDir => {
    const router = loadRoutesFrom(routesControllerDir);
    return (req, res, next) => {
        const match = router(req.method, req.path);
        if (!match) return next();
        const { handler, params } = match;
        req.params = params;
        return handler(req, res, next)
    }
};
```
This function takes the controller directory as input and returns a router function. The router function, when used as middleware in an Express application, intercepts incoming requests, matches the routes, and executes the corresponding handlers.

## Conclusion
Stay tuned for future blog posts where we'll delve deeper into the intricacies of routing, experiment with new ideas, and uncover even more ways to optimize our development process.

NB: some code sections in this post can be explained in more depth. I'm skipping that to keep it from being an unreadably long post. I'd love to elaborate in the comments.