beginner

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
Previous Creating a proper server
Next Creating NodeJS project