Creating proper routes
We have a proper server but we are still not handling our routes properly. They are still a mess so let’s do the same thing and rewrite existing router
function into a class. Afterwards we are going to move our routes into specific “routes” files. As with all of our code so far this will be very simplified to make it easier for you to understand what is going on under the hood when you use a framework or existing package for this.
Route
This new class is going to be a bit weird at first and don’t worry, I’ll go through all of it and explain. Before we start with the code itself let me first explain the overall gist of it. Remember that we have routes/endpoints and those are METHOD + STRING. It is possible that one string has multiple methods assigned to it so we need a way to store all of those. We also want these to be unique wherever we store them so we will use Map
. Map
is an object where a key may occur only once and that remembers the original insertion order of the keys.
Since we are storing key-value values then our key will be string but our value needs to be another map where keys will be methods and their handler the value.
Given an example of endpoint users/test
with both GET
and POST
methods we will store them like this:
Our map:
...
"users/test" => ...
... "GET" => handler for get
... "POST" => handler for posts
...
Now let’s get to it. You need to create a new file Route.js
in the same folder as index.js
, routes.js
and Ignitor.js
.
Route.js
class Route {
#instance;
constructor() {
if (this.#instance) {
throw new Error("New instance cannot be created.");
}
this.#instance = this;
}
// This will hold our routes as keys and map of route handlers as values
// Route handler will have key as method and handler as value
// Because we can have more route handlers for one route based on method
#routeHandlers = new Map();
get routes() {
return this.#routeHandlers;
}
get instance() {
return this.#instance;
}
add(route, handler, method) {
if (!method) {
throw new Error("Method must be specified.");
}
// We want to save "/posts" and "posts" as "posts" so we remove the first "/"
route = this.sanitizeUrl(route);
// Check if route handler already exists
let routeHandlerMap = this.#routeHandlers.get(route);
if (routeHandlerMap) {
//It exists, so set(or overwrite handler for that method)
routeHandlerMap.set(method, handler);
} else {
// It doesnt exist, so create new Map for route value
routeHandlerMap = new Map();
// Set method as key and handler as value
routeHandlerMap.set(method, handler);
// Set route as key and routeHandler map as value
this.#routeHandlers.set(route, routeHandlerMap);
}
}
// Main handler for incoming routes
handle({ req, res }) {
const route = this.sanitizeUrl(req.url);
const method = req.method;
// Try to find route match in routeHandlers
let routeHandlerMap = this.#routeHandlers.get(route);
if (routeHandlerMap) {
// Try to find method match in routeHandlerMap
let methodHandler = routeHandlerMap.get(method);
if (!methodHandler) {
return res.end(
JSON.stringify({
success: false,
message: `Handler for method '${method}' for route '${route}' not found.`,
}),
);
}
return methodHandler({ req, res });
}
return res.end(
JSON.stringify({
success: false,
message: `Handler not found for route '${route}'.`,
}),
);
}
sanitizeUrl(url) {
if (url[0] == "/") {
url = url.substr(1);
}
return url;
}
// These functions below are just helpers functions with prettier names
get(route, handler) {
this.add(route, handler, "GET");
}
post(route, handler) {
this.add(route, handler, "POST");
}
patch(route, handler) {
this.add(route, handler, "PATCH");
}
put(route, handler) {
this.add(route, handler, "PUT");
}
delete(route, handler) {
this.add(route, handler, "DELETE");
}
}
module.exports = new Route();
Now that we have, once again, a singleton class Route we can create some routes. For that we want to rewrite our existing routes.js
file in the same folder.
routes.js
const Route = require("./Route.js");
Route.get("test", ({ req, res }) => {
return res.end(
JSON.stringify({
success: true,
message: "Works for GET.",
}),
);
});
Route.post("test", ({ req, res }) => {
return res.end(
JSON.stringify({
success: true,
message: "Works for POST.",
}),
);
});
Here you see that we are using closures to access req
and res
variables(and you’ll soon see where we are getting those values).
We are also using our get
and post
functions that are only wrappers around add
so that we don’t have to write:
Route.add('test', ({ req, res }) => {
return res.end(
JSON.stringify({
success: true,
message: 'Works for POST.',
}), "POST");
Updating our Ignitor.js and index.js
For all of this to actually work, we need to update our Ignitor and let it know that Route instance exists and make it available to Ignitor.
Ignitor.js
const http = require("http");
const Route = require("./Route.js");
const PORT = 3000;
class Ignitor {
#instance;
#route;
constructor() {
if (this.#instance) {
throw new Error("New instance cannot be created.");
}
this.#instance = this;
}
setup() {
this.#route = Route;
}
start() {
this.setup();
http
.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
return this.#route.handle({ req, res });
})
.listen(PORT);
}
}
module.exports = new Ignitor();
We have added new private property #route
that we assign Route
singleton instance to in our newly added method setup
that is called in start
so that we can use it later.
We also slightly change our .createServer
function by adding a header (because we are sending JSON back as specificed in our routes
responses) and we are returning whatever the response is from handle
function in our Route
. And here we are passing those req
and res
values to be used in closures.
And our final thing to change and to make it all work is to update our existing ìndex.js
by importing our routes
file with require
(this can be before or after Server.start()
but it is better to have it before).
index.js
const Server = require("./Ignitor.js");
require("./routes.js");
Server.start();
This is important because when we run node index.js
it will import our routes and in doing so it will execute whatever code is inside and we are importing Route
inside of that file so it executes that code as well and since we have instantiation in that file because of our singleton we are basically running the entire Route
code. Now that this was instantiated the next code that runs our routes code where they get created and then Server.start()
gets executed and inside we are getting our Route
(that now exists because of all the code that ran before it.) And that is how all of it comes to life.
Folder structures
index.js
routes.js
Route.js
Ignitor.js