Across every robust back-end language, there exists a mature set of tools – frameworks – that prevent developers from reinventing the wheel, abstracting low-level application logic details like HTTP protocol handling or database connections, and offering rapid development as a bargain.
However, the use of middleware or any middleware-like constructs became a standard practice, virtually common amongst many widely used frameworks.
Frameworks, such as Express.js, Ruby on Rails, Django, and Laravel, provide built-in support for defining and using middlewares as a core feature of their architecture.
Different web frameworks have different implementations and approaches to middleware architectures, but with it, the fundamental concept of pre-processing; authentication, authorization, Logging, and post-processing; response modification, error handling, and caching, — remains consistent.
And, at the very least, that’s the primary axiom of any backend tool; handling the HTTP request-response chain.
Hence, or otherwise, all roads lead to Rome.
Quite notably, Koa.js, a lightweight and modern backend framework focused on simplicity and flexibility, carved its fair share of popularity in the dev community. Koa.js also implements middleware and, as a cherry on top, introduces a context object that encapsulates both the request and response objects. Therefore, it simplifies data and state sharing across middleware functions, significantly enhancing the HTTP request-response chain in turn.
Yet, what truly sets KOA apart is its role in a uniquely evolved flow control; the 'Strapi HTTP Request Chain,' to which it contributes.
There are notable differences in how middlewares are implemented and used due to the design philosophies of these modern frameworks. These differences result in a distinct 'HTTP Request Flow' for Koa.
When you create a Koa application instance using the constructor object new Koa()
, you are essentially establishing the core of your web server, often referred to as app
.
1 // Import Koa
2 const Koa = require('koa');
3
4 // Instantiate a Koa app
5 const app = new Koa();
Now, this constructor object is equipped with valuable methods that:
3000
.app.use()
method.Middlewares are essentially asynchronous or common JavaScript functions that intercept, processes and, modify request data before it reaches the intended destination. For each incoming HTTP request, each of these middleware functions has access to the Koa context, bundling both the Request and Response objects into a single ctx object.
1// Import Koa
2const Koa = require('koa');
3
4// Instantiate a Koa app
5const app = new Koa();
6
7// Register middleware functions :D
8// Middleware 1
9app.use(async ctx => {
10 console.log(ctx.request); // Request object
11 console.log(ctx.response); // Response object
12});
13
14// Middleware 2
15app.use(async ctx => {
16 console.log(ctx.request); // Request object
17 console.log(ctx.response); // Response object
18});
Now, by using app.use()
to register various middlewares, Koa dynamically assembles an array of these 'middleware functions' and organizes them into a stack. Each middleware in this stack is executed sequentially, in the order they are added to the stack.
Let's take a closer look at how Koa executes these middlewares when a request hits the server.
Now, when a user sends an HTTP request to a Koa application server, this request enters a middleware pipeline.
Middleware functions execute one after the other, as per each incoming request, following a Last In, First Out (LIFO) order. This means that 'Middleware 1' starts first, followed by 'Middleware 2,' and finally 'Middleware'3'—this is the downstream HTTP flow of the request.
1// Register middleware functions :D
2// Middleware 1
3app.use(async (ctx, next) => {
4 console.log("Before Middleware 1"); // Executed first
5 await next(); // Control passes to Middleware 2
6 console.log("After Middleware 1"); // Executed last in Middleware 1
7});
8
9// Middleware 2
10app.use(async (ctx, next) => {
11 console.log("Before Middleware 2"); // Executed second
12 await next(); // Control passes to Middleware 3
13 console.log("After Middleware 2"); // Executed last in Middleware 2
14});
15
16// Middleware 3
17app.use(async (ctx, next) => {
18 console.log("Before Middleware 3"); // Executed third
19 await next(); // No middleware left to pass control to
20 console.log("After Middleware 3"); // Executed last in Middleware 3
21});
In the downstream flow, any code placed before await next()
within a middleware function is request-specific. This is the stage where you have full access to the request data, specifically through ctx.request
. Typically, this code is responsible for tasks such as logging, parsing request data, or conducting authentication checks, all of which prepare the context for the next middleware in line.
The await next()
function is available in every middleware function, alongside the ctx
object. It serves as a reference to the next middleware in the call stack, enabling orderly execution. This structure allows each middleware to handle request and response objects in a well-defined sequence, providing precise control over the request processing.
The sequence of execution continues until the last registered middleware. When the last middleware calls await next()
, it signals the end of the downstream flow. At this point, the request can engage in business logic, database interactions, and other specific tasks.
This marks the completion of the downstream flow, and the request is now ready for response processing.
After 'Middleware 3' completes its execution, control flows back "upstream." This means that 'After Middleware 3' is executed first, followed by 'After Middleware 2,' and finally 'After Middleware 1.' This reverse flow ensures that control returns to each middleware in the opposite order of execution.
Conversely, any code placed after await next()
within a middleware function is response-specific. This is the point where you have full access to the response object, which is represented by ctx.response
. In an upstream flow, it's common to perform cleanup and post-processing tasks.
Strapi is an open-source, API-driven headless CMS. Instead of rendering content into HTML pages on the server, Strapi makes it accessible via APIs.
Let's consider a practical example: building a blog. In this blog, when you want to store content for a blog post, Strapi offers a common approach. You can create a content type for that specific blog post, complete with various fields like author, date, article content, and comments. These fields store the author's name, the publication date, the actual article content, and comments from readers.
However, Strapi's core philosophy is 'Content Access via API.' As you create content types, Strapi automatically generates an API endpoint for each one. This means you have a URL that users can access from their browsers to read your blog posts—pretty cool, right?
But, what's even cooler is that you have the flexibility to preprocess every API request that interacts with your blog posts. For instance, you can restrict the number of API calls your articles can receive by creating a Route Policy with custom validation logic. You can also integrate external analytics with tools like Google Sheets. All of this becomes possible through the use of middleware.
The Strapi CLI command, yarn strapi generate
, offers several different things that you’re able to generate including basic APIs, custom Controllers for APIs, and Middlewares. But the ones we are concerned about here are the middlewares.
Select the ‘middleware’ option and press enter. The CLI will prompt different options, from which you can have different locations to store different types of middleware. However, it’s not super important as to where a middleware gets stored.
In general, there are two ways to run these middlewares, depending on whether you want them to be 'Globally Scoped,' meaning they run on every API request, whether it's for content or the admin API. This is where the 'Add middleware to the root of the project' option comes into play.
Alternatively, you can opt for 'Route Scoped' Middlewares, which execute when specific content types are requested. In this case, you can select 'Add middleware to an existing API.'
However, if you have multiple content types, a best practice is to choose 'Add middleware to the root of the project,' i.e., Globally Scoped. Every Strapi project you create comes with a config file, config/middlewares.js
, which includes an array stack of internal Strapi-defined Global middlewares. These middlewares handle various tasks, including error handling, security, and CORS handling.
Simply put, this config file is a 'call stack' for Global Middlewares.
However, any custom Global middleware generated from the Strapi CLI is located in src/middlewares
. Only when you have custom Global Middlewares generated from the CLI should you append them to this 'global call stack.' To do so, simply place the middleware anywhere within this config file. This means that the appended custom Global Middleware will execute on every HTTP request alongside Strapi-defined Global Middlewares.
For example, a custom Global middleware might appear as "global::customGlobalMiddleware"
1module.exports = [
2 "strapi::errors",
3 "strapi::security",
4 "strapi::cors",
5 "strapi::poweredBy",
6 "strapi::logger",
7 "global::customGloblalMiddleware",
8 "strapi::query",
9 "strapi::body",
10 "strapi::session",
11 "strapi::favicon",
12 "strapi::public",
13];
Regarding the order of execution, like any typical KOA middleware, these middlewares execute sequentially, in the order they’re added onto the stack. You can play around with the order of execution by manipulating the array contents.
Now, let's assume you choose to generate a route-based middleware. To do this, you should have at least one content type you've created. And it is in this content-type folder, src/api/content-type-name/middlewares
, where the generated route middlewares are stored.
When it comes to configuring route-scoped middlewares, they need to be registered in what's akin to a 'call stack' for them to execute. This is achieved in any of the route factories: find
, findone
, create
, delete
, of a content-type router, which is located in src/api/routes/content-type-name.js
.
For each content type you create, there's a corresponding content type router. The beauty of this approach is that you can call the same generated route-scoped middleware more than once to execute it in different content types simply by including them in the respective content-type router's call stack. This approach enhances modularity by avoiding code repetition.
1"use strict";
2
3/**
4 * content-type router
5 */
6
7const { createCoreRouter } = require("@strapi/strapi").factories;
8
9module.exports = createCoreRouter("api::blog-post.blog-post", {
10 config: {
11 find: {
12 policies: [ // Policy Call Stack
13 "api::blog-post.test-policy-one",
14 "api::blog-post.test-policy-two",
15 ],
16 middlewares: [ // Route Middleware Call Stack
17 "api::blog-post.test-route-middleware-one",
18 "api::blog-post.test-route-middleware-two",
19 ],
20 },
21 },
22});
The order of execution, however, remains consistent; following the good old sequential flow. Speaking of execution flows, in a broader context, you can observe how all these middlewares execute within a Request Chain, as illustrated below.
With each API request that reaches the HTTP Server within Strapi (which is built on Koa), it goes through a Request-Response cycle, as depicted below.
In a nutshell, this Request-Response cycle is the 'Strapi Request Chain.' Importantly, it doesn't deviate an inch from KOA's approach.
The Strapi Request Chain is an adaptation of the KOA Request Chain. The twist here is that the Strapi Request Chain is a more elaborate chain with a bigger bag of tricks. However, at its core, the Strapi Chain still adheres to several rules found in KOA's Chain, such as the LIFO execution order, middleware cascading, and the use of the callback function 'await next()'.
Each incoming request to the HTTP server within Strapi starts by executing Global Middlewares. At this stage, the request chain progresses to downstream execution. Since Global Middlewares are designed to execute for all API requests (both content API and admin API), it's a common best practice to write logic that applies universally to these requests. This might include tasks like sending analytics data to Google or implementing request-limiting logic per unit of time.
Furthermore, all the request logic code within a custom Global Middleware or any Middleware, for that matter, should be placed before the await next()
callback function. This is where you have full access to the request data, ctx.request
.
1'use strict';
2
3/**
4 * `custom Global Middleware` middleware
5 */
6
7module.exports = (config, { strapi }) => {
8 // Add your own logic here.
9 return async (ctx, next) => {
10 // Add Request-Specific Logic here
11 strapi.log.info('In a custom Global middleware.');
12 ctx.request
13
14 await next();
15
16 // Add Response-Specific Logic here
17 };
18};
These Global Middlewares, whether custom or Strapi-defined, execute sequentially as they're added to the array call stack. As they execute sequentially, if any of the Middlewares returns a value, such as an object, an array, or an error, that specific Middleware promptly interrupts the HTTP chain, responding to the user.
However, if they don't return anything, the flow of control for the HTTP request is passed on to the Routes.
Within the content-type router, src/routes/content-type-name
, basically, a developer can either sign one or both of the route-specific middlewares:
Inside the content-type router, located at src/routes/content-type-name
, developers have the option to include one or both of the route-specific middlewares:
Route Policies function as middlewares that enable developers to add route-specific custom logic, such as policies and validation logic.
You can generate and configure Route Policies in the same way as you do with Route Middlewares.
Route policies offer a means to define and enforce specific rules or filtering logic for each individual route within an application.
1module.exports = (policyContext, config, { strapi }) => {
2
3 console.log("I'm a Route Policy :)");
4
5 const canDoSomething = true;
6
7 if (canDoSomething) {
8 return true;
9 }
10 return false;
11};
Two key takeaways about Route Policies are: (a) Policies are read-only. Like any middleware function, they have access to a ctx
object as a parameter, or at least a copy of it called the policyContext
However, this policyContext is read-only
It is still sufficient for manipulating the request object, policyContext.request
, to perform various validation steps.
And (b), Route Policies are exclusively "request-only operations," existing solely in the downstream flow of the HTTP cycle.
Depending on the validation outcome, a policy can return one of three results: true
for success, false
for failure, or an error
in case of validation errors.
When the policy returns true
, the flow proceeds to Route Middlewares.
Following Policies, Route Middlewares are the next in line to execute.
Route Middlewares are executed exclusively when requests are sent to the content API.
Throughout the entire HTTP chain, every request carries a 'State,' ctx.state
. This State serves as a central storage accessible only starting in Route Middlewares (including Policies). Within this state, you can access a user as ctx.state.user
, or more specifically, a user ID as ctx.state.user.id
.
This becomes invaluable when creating any logic that involves mapping relations with the authenticated user. One prime example is the 'is-owner' policy.
1module.exports = (config, { strapi }) => {
2 // Add your own logic here.
3 return async (ctx, next) => {
4 // Request-Specific Operations
5 console.log("Before Route Middleware");
6 await next();
7 // Response-Specific Operations
8 console.log("After Route Middleware");
9 };
10};
Route Middlewares provides control over the request flow and offer significant flexibility in modifying the request itself before it proceeds further in the application to the controllers.
As the request continues from the Route Middleware, it is at this point that a controller can modify a Response object ctx.response
, shaping the structure of the response.
After completing the routing phase, when a matching route is identified and all Middlewares finish execution, the chain reaches a point where it needs to fetch data from the database. This is where controllers come into play.
The request is then passed to the corresponding controller method, which is essentially a controller action. These actions are JavaScript functions, either async
or sync
, responsible for handling the business logic code and any complexity too specific for Middlewares. Additionally, controllers are responsible for creating a response structure for the user. A controller can have multiple actions, and just like middleware functions, these action functions also have access to the context object (ctx
) as a parameter
1"use strict";
2/**
3 * blog-post controller
4 */
5const { createCoreController } = require("@strapi/strapi").factories;
6
7module.exports = createCoreController(
8 "api::blog-post.blog-post",
9 ({ strapi }) => ({
10 async find(ctx) {
11 console.log("===================");
12 console.log("Before Controller - find");
13
14 const { data, meta } = await super.find(ctx); //
15
16 console.log("===================");
17 console.log("After Controller - find");
18
19 return { data, meta };
20 },
21 })
22);
However, controllers lack the next
callback function to reference the next component in the chain. Instead, controllers use the await super()
method, which works nearly the same as next
.
So, all the business logic inside the Controllers’ actions, right?
Well, this may pose a concerning issue if the logic happens to grow in considerably large amounts. To simplify the controller’s logic, a common best practice is to use services.
Services are a set of functions, but re-usable. They organize code into reusable parts, meaning you can create a single service that you can use in more than one controller. In turn, it reduces redundancy by following the DRY programming concept.
1"use strict";
2
3/**
4 * blog-post service
5 */
6
7const { createCoreService } = require("@strapi/strapi").factories;
8
9module.exports = createCoreService(
10 "api::blog-post.blog-post",
11 ({ strapi }) => ({
12 async find(params) {
13 console.log("===================");
14 console.log("Before Service - find");
15
16 const { results, pagination } = await super.find(params);
17
18 console.log("===================");
19 console.log("After Service - find");
20
21 return { results, pagination };
22 },
23 })
24);
Similar to controllers, Services also utilize the super
method to manage execution control.
Now, the code executed by these controllers and services interacts with the models, which represent the content data structure stored in the database.
However, if you choose not to use Services, Strapi provides the EntityService and QueryEngine API. You can use either of them directly in a controller and skip the need to build a service.
It is at this point, that once the chain reaches the controllers and services and requests data from the database, you might expect it to work its way back up the chain. That's the usual pattern, but not just yet.
Strapi takes things a step further by introducing some Lifecycles into this chain.
Lifecycles are essentially smaller middlewares at their core, wrapping around various database queries, whether it's for creating, updating, deleting, or any other action. Strapi provides predefined actions like beforeFindMany
, which triggers before the query is sent to the database, and afterFindMany
, which triggers after the query has been sent to the database, and now we have the response. These are essentially default middlewares added to Strapi as part of its abstraction layers.
1module.exports = {
2 beforeFindMany(event) {
3 console.log("===================");
4 console.log("Before Lifecycle - findMany");
5
6 // This isn't a legit log but adding it for clarity
7 console.log("===================");
8 console.log("Actual Database Query executed");
9 },
10
11 afterFindMany(event) {
12 console.log("===================");
13 console.log("After Lifecycle - findMany");
14 },
15};
These lifecycles provide a way to customize and intercept database actions at key points in the process.
In the Strapi Response Flow, once all the previous components in the request chain have done their work — things like Global Middlewares, Route Policies, Route Middlewares, Controllers, Services, and Lifecycles—it's time for a reverse flow control: Upstream Execution.
This phase is quite similar to the Request Flow, but it works backward after Lifecycles have been completed. It essentially means that, after lifecycles are done, control goes back to global middlewares, and the final response for the user is prepared.
In this flow, you can make some final adjustments, like formatting data, adding headers, or whatever is needed, before sending out the response to a user.
Wrapping it all up, Strapi offers you a rigid well-structured framework for creating routes and controllers. And no longer do you need to think about the complexities of database setup or content-type configurations. Generating all the controllers and APIs is just one yarn strapi generate
command away. But if you’re feeling a bit lazy, within the admin panel, you can define content types, generate routes, and have your database primed for data insertion.
Coupled with an elaborate Strapi HTTP Request Flow as a cherry on top, well, backend development haven’t been easier.
Strapi's methodology aligns well with other contemporary frameworks like Ruby on Rails (which follows the "Rails way" of doing things), Django (with its opinionated structure), and Laravel (which provides clear conventions for various aspects of web development). These frameworks provide a predefined structure for organizing code, naming conventions for models, views, and controllers, and default behaviors that developers can rely on.
So, it’s safe to say you can, however, use Strapi as a NodeJS backend framework.
Student Developer ~ Yet Another Open Source Guy ~ JavaScript/TypeScript Developer & a Tech Outlaw...