Decoding Backend Architecture: Unraveling the Mechanisms Behind Backend Operations - part 2
Understanding the responsibilities of a backend server is a keynote before diving deep into the coding session. A server is like a vault in public places, where anyone can get something from it by request but decoding the needs and validation of the request is up to the server to do.
Since the web is open, so, anyone can send a request to a server. By keeping this in mind the backend server has to go through various steps before serving a response. Let’s have a look at some possible steps that a server goes through.
- Find an appropriate handler for the request
- Authorize the request (if required for this operation)
- Validate the user input (if required for this operation)
- Attempt to perform the operation
- Handle the success/error cases and return the response
In a HTTP request the request path is used for determining the appropriate response for it. A response could be a file, text, JSON, HTML, etc. For this session, we’ll focus on an API server with a JSON response. There are multiple standards of methods for building an API server, like HTTP RPC, JSON RPC, GraphQL, REST, (more about it later), etc. The most commonly used one is REST API. We’ll focus on the REST API in this session.
Find an appropriate handler for the request
It’s a very basic and easy step, right? Just check the path and perform a handler for this path. But here we are missing a very important design concept in route matching, let’s understand the problem of this approach.
const http = require('http');
const server = http.createServer((req, res) => {
if(req.url === '/user/1' && req.method === "GET") {
res.end('user one\n');
return;
}
if(req.url === "/user/2" && req.method === "GET") {
req.end('user two\n');
return;
}
});
In this block of code, we have two major issues. The first one is the checking path part, which can’t define a route for every user, and the second one is the request method. As we know the request method has a very important role in term of the REST API, the same URL cloud do different operations by different request methods.
Let’s solve those problems, we’ll define an array of available handlers and a pattern for the path.
const http = require('http');
const Route = require('route-parser');
const routes = [
{
method: "GET",
path: "/api/users/:id",
handler: (req, res, params) => {
// get the user
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: true,
user: `The user id is ${params.id}`
})
);
}
}
];
const routeParser = (url, method) => {
for (const route of routes) {
// match the method first
if (method !== route.method) {
continue;
}
// since the method got matched let's match the route
const parser = new Route(route.path);
const match = parser.match(url);
if (match) {
return {
...route,
params: match
};
}
}
// nothing found
return null;
}
const server = http.createServer((req, res) => {
const route = routeParser(req.url, req.method);
if(route) {
return route.handler(req, res, route.params);
}
// Set the response headers for 404 Not Found
res.writeHead(404, {'Content-Type': 'application/json'});
// Send the "404 Not Found" response
res.end(JSON.stringify({status: false, message: "Not found"}));
});
Let’s understand, that we are using a package for matching routes, it’ll return the params of the route
when matched otherwise false
. It’s more manageable and solves the route param’s problems as well.
Authorize the request (if required for this operation)
Authorizing a request is very important, as of now anyone can make a request to that endpoint to get the user’s information. You might want to expose some routes with public access, but let’s learn the concept of middleware by introducing authentication on this request.
Well, there are many ways of authorizing users on an application. It’s a concept for another series, for now, let’s implement a very basic authorization for this route.
/**
*
* @param {http.IncomingMessage} req
* @param {http.IncomingMessage} res
*/
const authorize = (req, res) => {
// allow the request the the request header container token="user-request"
if (req.headers["token"] === "user-request") {
return true;
}
res.writeHead(401, { "Content-Type": "application/json" });
res.end(
JSON.stringify({ success: false, message: "You are not authorized." })
);
};
const routes = [
{
middlewares: [authorize],
method: "GET",
path: "/api/users/:id",
handler: (req, res, params) => {
// get the user
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: true,
user: `The user id is ${params.id}`
})
);
}
}
];
const routeParser = (url, method) => {
for (const route of routes) {
// match the method first
if (method !== route.method) {
continue;
}
// since the method got matched let's match the route
const parser = new Route(route.path);
const match = parser.match(url);
if (match) {
return {
...route,
params: match
};
}
}
// nothing found
return null;
}
const server = http.createServer((req, res) => {
const route = routeParser(req.url, req.method);
if(route) {
// if the route has any middleware then let's apply them
// either the route doesn't has any middleware or all the middleware should return true
const allowed =
!route.middlewares.length ||
!route.middlewares.some(
middleware => !middleware(req, res, route.params)
);
if(!allowed) {
return;
}
return route.handler(req, res, route.params);
}
// Set the response headers for 404 Not Found
res.writeHead(404, {'Content-Type': 'application/json'});
// Send the "404 Not Found" response
res.end(JSON.stringify({status: false, message: "Not found"}));
});
Validate the user input (if required for this operation)
Any operation that takes data from the user should have some validation. Let’s say in this case the id in our database is integer
but the user could put anything in the URL. So, it’s pretty obvious to sanitize this data before performing any operations. In our case it’s pretty easy now, We could have many routes that accept an ID from the request path we could define a middleware for validating this kind of request.
// creating new middleware
/**
*
* @param {http.IncomingMessage} req
* @param {http.IncomingMessage} res
* @param {{ id?:string | number; }} params
*/
const requiredId = (req, res, params) => {
if (!params.id) {
res.writeHead(422, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, message: "Please provide a ID" }));
return;
}
if (Number.isNaN(Number(params.id))) {
res.writeHead(422, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: false,
message: "The ID should be a valid number"
})
);
return;
}
return true;
};
// added the new middleware for this route
const routes = [
{
middlewares: [authorize, requiredId],
method: "GET",
path: "/api/users/:id",
handler: (req, res, params) => {
// get the user
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: true,
user: `The user id is ${params.id}`
})
);
}
}
];
It’s not the best idea to do validation this way, but in our case, it’ll be OK.
Handle the success/error cases and return the response
It’s really important to handle errors. In any case, we shouldn’t expose the error to the user, it may contain any sensitive information. Let’s add a top label try catch
block.
const http = require("http");
const Route = require("route-parser");
/**
*
* @param {http.IncomingMessage} req
* @param {http.IncomingMessage} res
*/
const authorize = (req, res) => {
// allow the request the the request header container token="user-request"
if (req.headers["token"] === "user-request") {
return true;
}
res.writeHead(401, { "Content-Type": "application/json" });
res.end(
JSON.stringify({ success: false, message: "You are not authorized." })
);
};
/**
*
* @param {http.IncomingMessage} req
* @param {http.IncomingMessage} res
* @param {{ id?:string | number; }} params
*/
const requiredId = (req, res, params) => {
if (!params.id) {
res.writeHead(422, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, message: "Please provide a ID" }));
return;
}
if (Number.isNaN(Number(params.id))) {
res.writeHead(422, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: false,
message: "The ID should be a valid number"
})
);
return;
}
return true;
};
const routes = [
{
middlewares: [authorize, requiredId],
method: "GET",
path: "/api/users/:id",
handler: (req, res, params) => {
// get the user
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: true,
user: `The user id is ${params.id}`
})
);
}
}
];
const routeParser = (url, method) => {
for (const route of routes) {
// match the method first
if (method !== route.method) {
continue;
}
// since the method got matched let's match the route
const parser = new Route(route.path);
const match = parser.match(url);
if (match) {
return {
...route,
params: match
};
}
}
// nothing found
return null;
};
const server = http.createServer((req, res) => {
try {
const route = routeParser(req.url, req.method);
if (route) {
// if the route has any middleware then let's apply them
const allowed =
!route.middlewares.length ||
!route.middlewares.some(
middleware => !middleware(req, res, route.params)
);
if (!allowed) {
return;
}
return route.handler(req, res, route.params);
}
} catch (error) {
console.error(error);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({ status: false, message: "Internal server error" })
);
return;
}
// Set the response headers for 404 Not Found
res.writeHead(404, { "Content-Type": "application/json" });
// Send the "404 Not Found" response
res.end(JSON.stringify({ status: false, message: "Not found" }));
});
// Set the server to listen on port 3000
const port = 3000;
server.listen(port, () => {
console.log(`Server running at http://localhost:${port}/`);
});
Finally, we have a fully functional NodeJs
server. That’s it for this session, but there is a lot more learning curve. We’ll discuss some of them on another topic. Thanks for reading!