Report API Gateway Project Carugno
Cloud Computing Technologies
A.A. 2022-2023
Setup
To develop the project the first steps are the installation of Visual Studio Code, the code editor, and Node.js, JavaScript run-time environment. The specific versions used for the project are version 1.80.2 for Visual Studio Code and version 18.17.0 for Node.js. A new folder is created as parent folder for the project in Visual Studio Code. To test the successful installation of Node.js the following commands are run from the terminal:
- node -v: It prints the available version of Node.js if the installation was successful.
- npm -v: With Node.js is also included npm (Node Package Manager), which is used to install and manage JavaScript packages (for comparison it is the equivalent of pip in Python). If the npm installation was successful too this command prints the npm version included with Node.js previously downloaded.
If one of the two commands fail Node.js installation is performed again. Then, using VS Code, a new “main.js” file is created using the following example. Completed this step another command is run from the terminal:
- npm init: It generates a package.json file for the project. It is the place handling all the dependencies related to Node.js. Once lunched it will ask to insert some information (package name, version, description, entry point, test command, git repository, keywords and license). Only “package name” and “entry point” are manually defined, meanwhile Enter is clicked instead over all the others fields to use the default values. After all the fields are completed Enter is pressed again to confirm the setup. The value used for “package name” is “api-gateway-project” while for the “entry point” is “main.js”, where the entry point is the name of the file that starts the execution.
Two additional important packages are installed through further commands:
- npm install express: it installs the “express” package, which stays for Express, the web framework for Node.js. It has modern HTTP utility methods and middleware capabilities required for developing backend components, like gateways and servers, using JavaScript.
- npm install nodemon: it installs the “nodemon” package. In many guides and tutorials, while not mandatory, it’s strongly suggested. The reason is that all components are going to be constantly listening on local ports to handle requests and responses. So deploying any change to a component requires to interrupt it, save the change and then restart it using the command “node [filename].js”. With this package components are started instead using “npx nodemon [filename].js”. The difference now is that, once a change is saved, the related component is automatically restarted. This way updates become available in real-time without having to restart their applications, immediately displaying the results. Such trick turned out to save ton of time and to improve a lot DX, being the project already very challenging because of the self-learning of new tools and the complexity of the topic.
Architecture
The structure of the project is made of the client running a browser, the API Gateway and two backend servers. The user interacts with the client and sends requests through the browser to the Gateway. The Gateway receives the request and forwards it to one of the backend servers. The server receiving the routed request, handles it and sends back the response to the client. During this whole workflow the Gateway interacts and monitors the health status of the servers.
Implementation
The first part is initializing an Express application for each component. Two new subfolders are created, each one for a different server, one with a new “server1.js” file, the other with a new “server2.js” file. Now that Express is ready “main.js”, “server1.js” and “server2.js” are all updated with the same Express “Hello world” template. It has the import of the Express package, the initialization of the Express application and the assignment of a local port to it. For the project the Gateway gets the local port 3000, server 1 the 3001 and server 2 the 3002. Backend servers source files are identical, differing only for local port number and message files. This decision resulted from a relevant problem: the necessity of having a running, fully working server to use both as backup and comparison against the one under development. The introduction of new features often led to errors, which were not caused by bugs in the code but from the fact, to make the new functionalities work correctly, changes also to the headers of responses and requests, different configuration parameters and the combination of different modules were needed to achieve the expected behaviour. So having the two servers as different source files turned out to be great for both learning and testing purposes. For example if the Gateway was not able to reach the backend servers, but they were both healthy and running, then there was a problm in the Gateway. If instead one of the servers received a new update and the Gateway could not reach it anymore with the other server still healthy and running the problem was in the updated server and so on. All three components are started by navigating to their folders and then lunching them from the terminal through the following command “npx nodemon [filename].js”. The code snippets presented in this section are shared between Gateway and servers, with only minor differences. These first few lines are the ones related to the initialization of the Express backend application:
const express = require('express')
const app = express()
const port = 3000 // 3001 and 3002 for backend servers
To switch connection protocol from HTTP to HTTPS using TSL it is necessary another external component: OpenSSL. After downloading and installing the correct version for the OS running on the client, it is used to generate a private key and certificate for securing the communication channels. From inside the folder of each component source file the following command is run from the terminal:
“openssl req -nodes -new -x509 -keyout [private_key].pem -out [certificate].pem”,
where [private_key] and [certificate] are the names to use for the .pem (Privacy Enhanced Mail) files generated storing the private key and the certificate for each project component. It signals a new request for a private key and a certificate. The “-nodes” (no Data Encryption Standard”) option disables their passwords, helpful for testing and development, while the “-x509” option makes the certificates self-signed, not having access to a third-party CA (Certification authority) to verify them. The command results in the terminal asking for additional information to identify the entity making the request: [Country Name (2 letter code), State or Province Name (full name), Locality Name (eg, city), Organization Name (eg, company), Organizational Unit Name (eg, section), Common Name (e.g. server FQDN or YOUR name), Email Address]. Enter is pressed over all the fields to use default values. Once the private key and the certificate have been generated, the Node.js “fs” module (File System, allowing to perform different files operations), is used to read the private key-certificate pairs .pem files. Then they’re stored in an object as values assigned to their respective attributes, “key” for the private key and “cert” for the certificate, according to Node.js HTTPS documentation. Also another attribute has to be added to the object otherwise any HTTPS connection attempt will fail. The “rejectUnauthorized” attribute is specified and its default value changed from True to False. This subtle change was discovered and added to modify the configuration of the components because it turned out that for security reasons browsers reject self-signed certificates by default. Doing this adjustment leads to the correct initialization of the “options” object with the parameters required for HTTPS connections. Once the object is ready it is passed as argument to the “createServer” function along with the Express application to start the components with HTTPS. The below snippet is shared between all the components, the only difference being the paths to private keys and certificates and the message printed to terminal for debugging purposes.
const fs = require('fs')
const https = require('https')
const options = {
key: fs.readFileSync('Security/private_key_API_gateway.pem'),
cert: fs.readFileSync('Security/certificate_API_gateway.pem'),
rejectUnauthorized: false,
};
https.createServer(options, app).listen(port, () => {
console.log(`Load Balancer is running on port ${port}`);
});
From now on one file is going to be modified to become the Gateway, the other two instead are going to become the backend servers.
- Gateway
First of all the Gateway needs to know the location of the two servers to route requests to them. The servers are going to be declared as an array of objects, each one initially with only one attribute, whose value is the path to the port that it has been assigned on the local machine at the start. An IP address is not needed because, by using “localhost”, the requests are not forwarded to the internet. They use instead the special loopback address 127.0.0.1, which references to the current local machine. So incoming requests from the client are going to be routed offline to the different ports assigned locally to the components of the machine on which the project is running. Then with the above addition of HTTPS the first change is replacing “http” with “https” in the addresses to reach the servers. Also a Boolean “healthy” flag is added for monitoring purposes.
const backendServers = [
{ target: 'https://localhost:3001', healthy: true },
{ target: 'https://localhost:3002', healthy: true },
];
Then proxying capabilities are introduced in the Gateway through the library “http-proxy”. Two features are added to the project using it: routing middleware to the backend servers and requests timeouts.
- Routing middleware: the proxy component is implemented to handle the communication between Gateway and servers. It proxies requests being exchanged in the backend. It’s worth noting its initialization with the “secure” flag set as False. This change resulted from the introduction of HTTPS using self-signed certificates. It is required to make the implementation consistent with the other components by making also the proxy accept self-signed certificates, rejected by default instead. This was one of the reasons why the backend communication with the servers was not working initially.
const httpProxy = require('http-proxy');
const proxy = httpProxy.createProxyServer({ secure: false });
- Timeouts: they’re added to requests being proxied by setting a timeout flag (in milliseconds). If the timer for a request expires the connection is interrupted with returned error code 504.
proxy.web(req, res, { target: targetServer.target, timeout: 5000 });
A basic HTML login page is built with a form asking to insert and submit username and password. To authenticate clients the session middleware of the package “express-session” is used. It allows to store and retrieve session data for each user. The client session is set with the following parameters: “secret”, which is a string representing the session ID cookie, ”resave”, set to false to store session data only once so not during every request, and “saveUninitialized”, set to True to implement session-based authentication and to provide each user with a unique session to better identify them. The last “express.urlencoded” line is another middleware added with the flag “extended” set to True, flag needed to parse the URL-encoded data received from the HTML form for authentication.
const session = require('express-session');
app.use(session({
secret: 'my-secret-key',
resave: false,
saveUninitialized: true,
}));
app.use(express.urlencoded({ extended: true }));
To add the load balancing to the Gateway an index is used to track the server that is going to receive the next incoming request. The index is used along with three simple functions that define the load balancing logic, each one being in charge of one aspect:
- getHealthyServers() returns only the servers that are flagged as healthy
- getNextTargetServer() returns the healthy server receiving the next request from Gateway
- rotateBackendServer() updates the index used to identify the next server
Each time a request arrives, the Gateway calls all these functions to decide the next target server:
let currentServerIndex = 0;
function getHealthyServers() {
console.log(backendServers)
return backendServers.filter(server => server.healthy);
}
function getNextTargetServer() {
const healthyServers = getHealthyServers();
return healthyServers[currentServerIndex];
}
function rotateBackendServer() {
currentServerIndex = (currentServerIndex + 1) % getHealthyServers().length;
}
Rate limiting is implemented through the “express-rate-limit” package, a middleware for Express. The limiter is initialized using a rate limiting window and a maximum threshold of requests per client per window. Without additional options the limiter is applied to all types of requests.
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({ windowMs: 60 * 1000, max: 100, });
app.use(limiter);
- Server
The server code is more simple. It’s the same for the two backend servers excluding the local port numbers, 3001 for server 1 and 3002 for server 2. However just using the servers code from the Express guide does not work. The Gateway receives a request and forwards it to a server. The browser, however, by default, for security reasons, enforces the same-origin policy. It notices that the response is being forwarded to the client, which is different than the origin of the request, the Gateway. The Gateway URL is called “current origin”, meanwhile the client is called “cross-origin”. CORS (Cross-Origin Resource Sharing) is the extension of the same-origin policy. It is implemented to enable load balancing and the interaction of the request with third-party domains, that are different from the origin. To do so a middleware Express function is used. The relevant part are the res.setHeader(…) lines because they change the default behaviour of the browser by letting it access the response from the server. It is placed at the start of the server code after the port assignment to make sure that all requests go through it and are updated to allow CORS.
- res.setHeader(‘Access-Control-Allow-Origin’, ‘*’); -> the ‘Access-Control-Allow-Origin’ header specifies which origins are the ones that servers can share responses with. Setting the wildcard ‘*’ as literal value tells the server to share the response with any origin.
- res.setHeader(‘Access-Control-Allow-Methods’, ‘GET, POST, PUT, DELETE’); -> the ‘Access-Control-Allow-Methods’ header specifies the methods allowed to access the server content. Setting ‘GET, POST, PUT, DELETE’ as literal value describes the types of the requests that the server is allowed to receive.
- res.setHeader(‘Access-Control-Allow-Headers’, ‘Content-Type’); -> the ‘Access-Control-Allow-Headers’ specifies the headers that can be used in a request incoming to the server from the origin. Setting ‘Content-Type’ as literal value allows requests having the ‘Content-Type’ header, which is the way used for the project, where the data is in the request body.
The next() function is used to signal the end of the processing of the response to the current middleware. So the response can either be sent to another following middleware or be routed. Since this is the only middleware present in the servers, after it requests will continue to the defined routes.
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
next();
});
Backend servers have only two paths: the first one is the ‘/’ root path and the second is the ‘/heartbeat’ path. Both these paths just handle GET requests.
- The ‘/’ root path receives the requests from the Gateway and sends back a response with a message revealing the backend server to which the Gateway routed the request to. By reloading the browser page the new request will be routed by the Gateway to the next available server so the returned message will show the new server that handled the request. This is the result of the load balancing feature of the Gateway.
- ‘/heartbeat’: this path is private and hidden to the client. It is used in the background by the Gateway as exclusive route to ping the servers and monitor their status. If a server receives a ping and it’s online it sends back a response with a message notifying its healthy status.
app.get('/', (req, res) => { res.send('Hello from Backend Server 3001!'); });
app.get('/heartbeat', (req, res) => {
res.send('Backend Server is healthy!');
});