Before moving on to plugins and hooks, I'd like to dive into an interesting concept supported by Sails: controller-based routing. When I say controller-based routing in Sails, I mean defining routes like this in the config/routes.js file:
module.exports.routes = {
'GET /': 'HomeController.index'
};
Sails automatically maps this route to the index method of the HomeController.js file.
module.exports = {
index: (req, res, next) => {
// ...something...
},
};
I find this very interesting. So, I thought, why not implement this from scratch to understand how it works?
This chapter is part of my book, Understanding and Using Sails.js, which I'm writing and publishing chapter by chapter.
You can read the first few chapters for free on this blog. Visit the book's dedicated page for more information, or head over to Gumroad to purchase a copy and/or support the ongoing writing of this book.
Our implementation will be loosely based on Sails and is not intended for production applications. The code we're going to write is for educational purposes only. So, consider adding proper validation, error handling, and other improvements if you plan to use something similar in a production application.
As you know, Sails is built on top of Express. In other words, the routes we define using the above syntax are eventually registered as regular Express routes using methods such as app.get(), app.post(), and so on. We're not going to use Sails for this exercise. Instead, we'll use Express directly. This will help us understand how a framework can take a configuration object, resolve the corresponding controller action, and register it as a regular Express route.
Let's get started!
---
Let's create a folder named contacts2. Go inside the folder and create a package.json file.
mkdir contacts2
cd contacts2
npm init -y
Install Express (express) as a production dependency because that's the foundation we need to build our web application in Node.js. We'll also install Nodemon (nodemon) as a development dependency (-D) to make the development workflow easier.
npm i -E express npm i -E -D nodemon
Let's add the dev and start scripts in the package.json file. We'll use Nodemon during development so that the application restarts automatically whenever we make changes. The start script will run the application directly using Node. Also, change the main field to app.js.
{
"name": "contacts2",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"dev": "nodemon app.js",
"start": "node app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"express": "5.3.0"
},
"devDependencies": {
"nodemon": "3.1.14"
}
}
We've referred to an app.js file, but we haven't created it yet. Let's create this file.
touch app.js
Open the app.js file and write the following code:
const express = require("express"); const app = express(); app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
We can easily run this application in development mode using the dev script.
npm run dev
You should see output similar to this:
[nodemon] starting `node app.js` Application is up and running on port 3000
With these changes, our basic Express application is up and running.
---
Let's add three routes: GET /, GET /pages/contact, and POST /users to demonstrate our implementation. I'll just return simple messages for all these routes.
const express = require("express"); const app = express(); app.get("/", (req, res, next) => { res.json({ message: "home page" }); }); app.get("/pages/contact", (req, res, next) => { res.json({ message: "contact us" }); }); app.post("/users", (req, res, next) => { res.json({ message: "user created" }); }); app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
Test these routes in your browser and/or Insomnia to confirm that they are working correctly. With these changes, our application now supports all three routes.
Now, we're going to change the structure of the application without changing its output.
---
Go ahead and create a routes.js file in the root folder of the project. You could create this file inside a config folder as well, but I always prefer keeping some files in the root folder to get started. I mean, the starting point should be treated as special.
touch routes.js
Open the routes.js file and write the following code:
module.exports = {
"get /": "HomeController.index",
"get /pages/contact": "PagesController.contact",
"post /users": "UsersController.create",
};
We need to create files named after the controllers, such as HomeController.js, PagesController.js, and UsersController.js. We can put these files anywhere we want, but let's put them inside a controllers folder. So, we need to create that folder first.
mkdir controllers touch controllers/HomeController.js touch controllers/PagesController.js touch controllers/UsersController.js
Open HomeController.js and write the following code:
module.exports = {
index: (req, res, next) => {
res.json({ message: "home page" });
},
};
Nothing unusual here. First, we defined the index method and then copied the code we previously had for the GET / route in the app.js file. Let's write similar code for the other two files as well. Open PagesController.js and write the following code:
module.exports = {
contact: (req, res, next) => {
res.json({ message: "contact us" });
},
};
Finally, open UsersController.js and write the following code:
module.exports = {
create: (req, res, next) => {
res.json({ message: "user created" });
},
};
With these changes, our route definitions and their corresponding controller actions are ready. Next, we need to connect them.
---
In the app.js file, go ahead and remove the three route definitions, as we've moved that code into separate files.
const express = require("express"); const app = express(); app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
To call methods from our controller files, we first need to import them. For this, we can use the require-all npm package. This package loads all the files from a directory and makes them available through an object.
Go ahead, stop the server, and install this package.
npm i -E require-all
As mentioned in the package documentation, we can write the following code:
const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, });
The controllers object now contains references to the modules loaded from the controllers folder. For example, to get a reference to the index method from HomeController, we can simply write:
controllers.HomeController.index()
Let's use the require-all package in our app.js file and load all the controllers.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
With this, we have figured out how to load our controllers dynamically. Next, we need to fetch the route definitions from the routes.js file and attach the corresponding methods to each route. Let's simply require the routes.js file to fetch all the routes.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); const routes = require("./routes"); app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
We're going to use a for...in loop to iterate over the routes object.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); const routes = require("./routes"); for (let route in routes) { // We'll process each route here. } app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
The route is the key, and it contains two things: the HTTP method and the route path.
For example:
"get /pages/contact": "PagesController.contact"
We need to extract both pieces of information from this string. To extract the HTTP method, we can use a regular expression like this:
const verbExpr = /^(get|post|put|delete)\s+/i; const match = route.match(verbExpr); const verb = match ? match[1].toLowerCase() : null;
The first line defines a regular expression that matches four HTTP methods. The second line checks whether the route matches that expression. Finally, we extract the HTTP method and store it in the verb variable. The i flag makes the matching case-insensitive. We convert the extracted method to lowercase because we'll use it to access the corresponding Express method.
Let's add these lines to our app.js file.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); const routes = require("./routes"); for (let route in routes) { const verbExpr = /^(get|post|put|delete)\s+/i; const match = route.match(verbExpr); const verb = match ? match[1].toLowerCase() : null; } app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
Next, we need to extract the route path. This is as simple as replacing the HTTP method and the following whitespace with an empty string. Whatever remains is the route path.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); const routes = require("./routes"); for (let route in routes) { const verbExpr = /^(get|post|put|delete)\s+/i; const match = route.match(verbExpr); const verb = match ? match[1].toLowerCase() : null; let path = route; path = path.replace(verbExpr, ""); } app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
We now have the HTTP method and the route path. We still need to figure out which controller method we should call.
---
Even though all the controller methods are available through the controllers object, we cannot call them dynamically using a single string. We need to extract the controller name and method name separately. To do that, we can split the route definition's value using the dot (.) character.
let location = routes[route]; location = location.split("."); let controllerLocation = location[0]; let methodLocation = location[1];
The controller name and method name are now available separately. We can use them to access the corresponding controller and method from the controllers object.
let controller = controllers[controllerLocation]; let method = controller[methodLocation];
Let's put everything together in our app.js file.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); const routes = require("./routes"); for (let route in routes) { const verbExpr = /^(get|post|put|delete)\s+/i; const match = route.match(verbExpr); const verb = match ? match[1].toLowerCase() : null; let path = route; path = path.replace(verbExpr, ""); let location = routes[route]; location = location.split("."); let controllerLocation = location[0]; let methodLocation = location[1]; let controller = controllers[controllerLocation]; let method = controller[methodLocation]; } app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
With these changes, we now have the HTTP method, route path, and controller method to call.
The only code we need to register the route with Express is:
app[verb](path, method);
It dynamically accesses the corresponding Express method, such as app.get() or app.post(), and passes the route path and controller method as arguments.
Let's add this line to our app.js file to complete the working Express application.
const express = require("express"); const app = express(); const controllers = require("require-all")({ dirname: __dirname + "/controllers", filter: /(.+Controller)\.js$/, }); const routes = require("./routes"); for (let route in routes) { const verbExpr = /^(get|post|put|delete)\s+/i; const match = route.match(verbExpr); const verb = match ? match[1].toLowerCase() : null; let path = route; path = path.replace(verbExpr, ""); let location = routes[route]; location = location.split("."); let controllerLocation = location[0]; let methodLocation = location[1]; let controller = controllers[controllerLocation]; let method = controller[methodLocation]; app[verb](path, method); } app.listen(3000, () => { console.log("Application is up and running on port 3000"); });
Go ahead and restart the application. Test all three routes again in your browser and/or Insomnia. You should see the same output as before.
---
The above implementation is not complete. I've intentionally left out many scenarios and improvements. For example:
- You can support all the HTTP methods, including
PATCH,OPTIONS, andHEAD. - You can move the route registration logic into a separate file.
- You can use better names for the variables.
- You can implement your own version of the
require-allpackage, as this package is quite small and consists of just one file. - You can add error checking. What if someone forgets to add a
.between the controller name and method name? Or what if they accidentally add a tab instead of a space between the HTTP method and the route path? - You can handle cases where the controller or the specified method doesn't exist.
These are just a few ideas. There are plenty of other things we could improve, but I think this is enough for our educational implementation.
No comments:
Post a Comment