Middleware
Learning objectives
- Knows the concept middleware.
- Knows how oak uses middleware to process requests.
- Can create middleware functions and use them in web applications.
In the first example with oak, we encountered an error There is no middleware to process requests. The application looked as follows.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
app.listen({ port: 7777 });
Middleware is a key part of the oak framework and, in practice, oak is a middleware framework. So, what is middleware?
Introducing middleware
Middleware are functions that process requests made to the server and which are always executed in a specific order. In oak, middleware functions have access to the context object and to a function called next
, which needs to be called for the next middleware function to be executed.
In the following example, we define a simple and familiar function that -- when a request is made to the server -- responds to the request with the message Hello world!
.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const greet = (context) => {
context.response.body = "Hello world!";
};
app.use(greet);
app.listen({ port: 7777 });
Now, we just mentioned that each middleware function has a function next
, which represents the next middleware function to be executed. Actually, the function greet
that we just defined above is also handled by oak as a middleware function; we just had not added the parameter next
to it, and thus that function is not available to us in the greet
-function.
In the next example, we have added the parameter next
, but do not yet call it.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const greet = (context, next) => {
context.response.body = "Hello world!";
};
app.use(greet);
app.listen({ port: 7777 });
When we try out the above application, there is no difference in behavior when compared to the previous one.
Let's change the application so that we call the function next
.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const greet = (context, next) => {
context.response.body = "Hello world!";
next();
};
app.use(greet);
app.listen({ port: 7777 });
The application still continues to work in a similar fashion.
Let us introduce another function. The function, called log
, is used to print a message to the console log whenever a request is made to the server. After printing the message, the function calls the function next
.
The function log
is added to the application through calling the application's method use
. We first add the log
function to the application, and then add the greet
function to the application.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const log = (context, next) => {
console.log(`Request made to ${context.request.url.pathname}`);
next();
};
const greet = (context, next) => {
context.response.body = "Hello world!";
next();
};
app.use(log);
app.use(greet);
app.listen({ port: 7777 });
Now, when we run the application and make a request to the server using curl http://localhost:7777/middleware
, we see the following message in the server log.
deno run --allow-read --allow-net app.js
Request made to /middleware
We also see the response from the server in the console window where we made the request using curl.
curl http://localhost:7777/middleware
Hello world!%
In practice, all functions that are added to oak with the method use
are middleware functions.
Beware, asynchronous functions hiding!
The next
function is actually an asynchronous function. Although these introductory examples here do not define the middleware functions as asynchronous, quite a few of our future middleware functions will be asynchronous to avoid trouble!
Question not found or loading of the question is still in progress.
Name and location of middleware functions
Middleware functions in our projects are typically placed into files in a folder called middlewares
. Depending on the way the project is structured, each middleware function can reside in its own file from where it is exported, or middleware functions can reside in a common file, from where they all are exported.
In practice, for the previously seen logging middleware, we could have a file called loggingMiddleware.js
in the project folder.
tree --dirsfirst
.
├── middlewares
│ └── loggingMiddleware.js
└── app.js
The loggingMiddleware.js
would contain the function log
that it would also export, as shown below.
const log = (context, next) => {
console.log(`Request made to ${context.request.url.pathname}`);
next();
};
export { log };
Then, the application in app.js
import the log
function from the loggingMiddleware.js
file and use it as follows.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
import { log } from "./middlewares/loggingMiddleware.js";
const app = new Application();
const greet = (context, next) => {
context.response.body = "Hello world!";
next();
};
app.use(log);
app.use(greet);
app.listen({ port: 7777 });
In the examples, we have placed the middleware functionality in the same file with the rest of the application for simplicity of presentation.
Some notes on naming
As we continue working on projects with multiple files that are divided into folders, you'll likely notice some overlap in the file and folder names. For example, middleware functionality resides in middlewares
-folder and the files that contain middleware-related functionality have a Middleware.js
-suffix (e.g. loggingMiddleware.js
). Similarly, when working with services, the services are in a folder called services
and have a Service.js
-suffix. While there is definitely redundancy in the naming, we'll continue using this style; this way, it's easier to recall what a file contains, even though the purpose of the file could be determined from the folder where the file is located in.
Processing middleware
Middleware functions are called in the order in which they have been added to the application. In the previous example, we created an application with two functions, log
and greet
. The function log
was added to the application first and greet
second. Let us illustrate the order by adding a log message to both functions.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const log = (context, next) => {
console.log(`Request made to ${context.request.url.pathname}`);
next();
};
const greet = (context, next) => {
console.log(`Greeting ${context.request.url.pathname}`);
context.response.body = "Hello world!";
next();
};
app.use(log);
app.use(greet);
app.listen({ port: 7777 });
Now, when we make a request to the server, we always see the log messages in the following order -- in the example below, we have made a request to http://localhost:7777/middleware
.
deno run --allow-read --allow-net app.js
Request made to /middleware
Greeting /middleware
If we change the order in which the functions are added to the application, the order of the logged messages changes. The only change that we do to the previous program is switching the order, as follows.
// ..
app.use(greet);
app.use(log);
// ..
Now, the order of the log messages, when a request is made to the server, is always as follows.
deno run --allow-read --allow-net app.js
Greeting /middleware
Request made to /middleware
In practice, when we call the method use
, the function given as a parameter to the method is added to a list. When a request is made to the server, a dispatcher function within oak passes the request to first middleware in the list. Whenever the function next
is called, the dispatcher then passes the request to the next middleware in the list. The dispatcher continues to pass the request forward whenever the next function is called, until there are no more middleware functions to be called.
Calling the function next
is crucial if we wish to pass the request to the next middleware. In the following example, we do not call the function next
in the greet
function.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const log = (context, next) => {
console.log(`Request made to ${context.request.url.pathname}`);
next();
};
const greet = (context, next) => {
console.log(`Greeting ${context.request.url.pathname}`);
context.response.body = "Hello world!";
};
app.use(greet);
app.use(log);
app.listen({ port: 7777 });
Now, when we make a request to the server, the request is never passed by the dispatcher to the middleware function log
. Thus, we do not see the message Request made to ...
.
curl http://localhost:7777/middleware
Hello world!%
curl http://localhost:7777/middleware
Hello world!%
deno run --allow-read --allow-net app.js
Greeting /middleware
Greeting /middleware
On the other hand, if we would reorder how the functions are added to the application, as shown below, we would see both messages.
app.use(log);
app.use(greet);
The following output is printed when we make two request to the address http://localhost:7777/middleware
.
deno run --allow-read --allow-net app.js
Request made to /middleware
Greeting /middleware
Request made to /middleware
Greeting /middleware
Now, both messages are shown as the middleware function log
calls the function next
. When the next
function is called in the log
function, the dispatcher passes the request to the next middleware in the list, which in this case is the function greet
.
Question not found or loading of the question is still in progress.
Asynchronous next
The function next
is an asynchronous function, and the promise that is the result of calling the next
function is fulfilled once all the subsequent middlewares in the list have been processed.
If we add an await
statement to the next
call, we can create functionality to the middleware where some parts of the code are executed before the subsequent middleware in the list, and some parts of the code after.
In the following example, we have modified the log
function so that it outputs both information on the request starting and on the request ending. As we use await
when calling the function next
, we have to also define the log
function as asynchronous.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const log = async (context, next) => {
console.log(`Request made to ${context.request.url.pathname}`);
await next();
console.log(`Request to ${context.request.url.pathname} completed`);
};
const greet = (context, next) => {
console.log(`Greeting ${context.request.url.pathname}`);
context.response.body = "Hello world!";
};
app.use(log);
app.use(greet);
app.listen({ port: 7777 });
Now, when we make a request to the application, we see the following output in the server console.
deno run --allow-read --allow-net app.js
Request made to /middleware
Greeting /middleware
Request to /middleware completed
Keep middleware functions asynchronous
While in some of the examples here we do not use await
when calling next, it should be done to avoid problems in subsequent middleware. Some middleware may depend on other middleware functions being finished; if an await is missing, then the execution of a middleware function that depends on the execution of others may be finished before subsequent middleware functions are finished.
A good rule of the thumb is to keep all middleware functions asynchronous.
Middleware use cases
Middleware functions process all requests made to the server. As the processing is not limited by path or request method, middleware allows adding cross-cutting functionality that is needed in all requests. Such functionality ranges from logging requests, as shown before, to logging errors, timing requests to the server, authenticating and authorizing users, etc. Here, we showcase a few of these.
Logging errors
A simple but useful use for middleware is logging errors that are not caught by the oak framework. The following middleware wraps the subsequent middleware into a try-catch -block, which catches errors thrown in the subsequent middleware.
The errors are then logged to the console, where they can be inspected.
const errorMiddleware = async (context, next) => {
try {
await next();
} catch (e) {
console.log(e);
}
};
Now, let's assume we have the following application. The application has a small bug in it, which we did not spot while coding.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const errorMiddleware = async (context, next) => {
try {
await next();
} catch (e) {
console.log(e);
}
};
const greet = ({ request }) => {
console.log(`Greeting ${request.url.pathname}`);
response.body = "Hello world!";
};
app.use(errorMiddleware);
app.use(greet);
app.listen({ port: 7777 });
Now, when we run the application, the application launches successfully. When we make a request to the server, we do not see a response -- instead, the server returns a 404 status code.
curl -v http://localhost:7777
// ...
> GET / HTTP/1.1
> Host: localhost:7777
> User-Agent: curl/7.58.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< content-length: 0
// ..
Now, something went wrong, but based on the above, we do not know what. Now, as the error messages are logged on the server, we can look into the logged message.
deno run --allow-read --allow-net app.js
Greeting /
ReferenceError: response is not defined
at greet (file:///path-to-file/app.js:15:3)
at dispatch (middleware.ts:41:13)
at errorMiddleware (file:///path-to-file/app.js:7:11)
at dispatch (middleware.ts:41:13)
at composedMiddleware (middleware.ts:44:12)
at Application.#handleRequest (application.ts:252:34)
at Application.listen (application.ts:385:28)
The above error message tells us, fortunately, quite clearly where the issue is coming from. The error message states that response is not defined, and further, the stack trace tells that the error happened on the 15th line of the app.js
file.
In practice, the variable response
that is used on the 15th line is not available -- this helps us in pinpointing the issue further. We had forgotten to include the response
variable to the parameters of the function greet
.
Timing requests to server
A classic use case for middleware is timing requests made to the server. This allows creating statistics on the request times for individual paths and request methods, which in turn provides insight on e.g. potential optimization opportunities.
The following example shows how such functionality could be implemented. The function Date.now()
returns the current timestamp in milliseconds counting from epoch (1st of January, 1970) -- the middleware function time
measures the time spent in processing the subsequent middleware.
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const time = async ({ request }, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${request.method} ${request.url.pathname} - ${ms} ms`);
};
const greet = (context, next) => {
console.log(`Greeting ${context.request.url.pathname}`);
context.response.body = "Hello world!";
};
app.use(time);
app.use(greet);
app.listen({ port: 7777 });
Now, when we make a few requests to the application, we see information on the time that it took from the server to process the requests.
deno run --allow-read --allow-net app.js
Greeting /middleware
GET /middleware - 0 ms
Greeting /middleware
GET /middleware - 1 ms
Greeting /middleware
GET /middleware - 0 ms
Serving static files
Another possibility would be to create a middleware responsible for serving static content from the server. In the following example, requests to any path starting with '/static' receive content from a directory static
. The example also shows the use of a function called send
that comes with oak.
import { Application, send } from "https://deno.land/x/oak@v12.6.1/mod.ts";
const app = new Application();
const serveStaticFiles = async (context, next) => {
if (context.request.url.pathname.startsWith("/static")) {
const path = context.request.url.pathname.substring(7);
await send(context, path, {
root: `${Deno.cwd()}/static`,
});
} else {
await next();
}
};
const greet = ({ response }) => {
response.body = "Hello world!";
};
app.use(serveStaticFiles);
app.use(greet);
await app.listen({ port: 7777 });
Now, any requests that are made to the path /static
are sent a response from the folder static
in the current directory. For example, if we make a request to http://localhost:7777/static/index.html
, we receive the file as a response, given that such a file exists. If a requested file does not exist, a not found response with status code 404 is sent instead.
In the above example, we set the static
folder under the current working directory (identified using Deno.cwd()
) as the root folder. This means that requests for static files cannot access files outside the folder static
and its child directories.