Skip to main content
Version: 2.x

Middleware

A middleware helps in addressing common crosscutting concerns without duplicating boilerplate code.

Definition

Middleware can be conceptualized as a functional component that accepts a Routes and produces a new Routes. The defined trait, Middleware, is parameterized by a contravariant type UpperEnv which denotes it can access the environment of the Routes:

trait Middleware[-UpperEnv] { self =>
def apply[Env1 <: UpperEnv, Err](routes: Routes[Env1, Err]): Routes[Env1, Err]
}

This abstraction allows middleware to engage with the Routes environment, and also the ability to tweak existing routes or add/remove routes as needed.

The diagram below illustrates how Middleware works:

Middleware Diagram

Usage

The @@ operator attaches middleware to routes and HTTP applications. The example below shows a middleware attached to an Routes:

import zio.http._

val app = Routes(
Method.GET / string("name") -> handler { (name: String, req: Request) =>
Response.text(s"Hello $name")
}
)
val appWithMiddleware = app @@ Middleware.debug

Logically the code above translates to Middleware.debug(app), which transforms the app using the middleware.

Attaching Multiple Middlewares

We can attach multiple middlewares by chaining them using more @@ operators:

val resultApp = routes @@ f1 @@ f2 @@ f3

In the above code, when a new request comes in, it will first go through the f3's incoming handler, then f2, then f1, and finally the routes, when the response is going out, it will go through the f1's outgoing handler, then f2, then f3, and finally will be sent out. So the order of the middlewares matters and if we change the order of the middlewares, the application's behavior may change.

Composing Middlewares

Middleware can be combined using the ++ operator:

routes @@ (f1 ++ f2 ++ f3)

The f1 ++ f2 ++ f3 applies from left to right with f1 first followed by others, like this:

f3(f2(f1(routes)))

Motivation

Before introducing middleware, let us understand why they are needed.

The Problem: Violation of Separation of Concerns Principle

Consider the following example where we have two endpoints:

  • GET /users/{id} - Get a single user by id
  • GET /users - Get all users
val routes = Routes(
Method.GET / "users" / int("id") ->
handler { (id: Int, req: Request) =>
// core business logic
dbService.lookupUsersById(id).map(Response.json(_.json))
},
Method.GET / "users" ->
handler {
// core business logic
dbService.paginatedUsers(pageNum).map(Response.json(_.json))
}
)

As our application grows, we want to code the aspects like Basic Authentication, Request Logging, Response Logging, Timeout, and Retry for all our endpoints.

For both of our example endpoints, our core business logic gets buried under boilerplate like this:

(for {
// validate user
_ <- MyAuthService.doAuth(request)
// log request
_ <- logRequest(request)
// core business logic
user <- dbService.lookupUsersById(id).map(Response.json(_.json))
resp <- Response.json(user.toJson)
// log response
_ <- logResponse(resp)
} yield resp)
.timeout(2.seconds)
.retryN(5)

Imagine repeating this for all our endpoints!

So there are some problems with this approach:

  • Violation of Separation of Concerns Principle: Our current approach conflates business logic with cross-cutting concerns, such as timeouts, which violates the Separation of Concerns Principle. This coupling complicates the maintenance and understanding of our codebase.
  • Code Duplication: Replicating cross-cutting concerns across multiple routes results in unnecessary code duplication. For instance, if there are 100 routes, each requiring a timeout, we're forced to repeat the same logic 100 times. Consequently, any modification or upgrade to a shared concern, like altering the logging mechanism, necessitates making changes in numerous locations, significantly increasing the risk of errors and maintenance effort.
  • Maintenance Nightmare: With this approach, even a minor alteration in a cross-cutting concern demands updating every corresponding route. This not only escalates maintenance efforts but also complicates testing and debugging of core business logic. Consequently, the overall maintenance cost and complexity of the system are amplified.
  • Readability Issues— This can lead to a lot of boilerplate clogging our neatly written endpoints affecting readability.

The solution: Middleware and Aspect-oriented Programming

If we refer to Wikipedia for the definition of an "Aspect" we can glean the following points.

  • An aspect of a program is a feature linked to many other parts of the program (most common example, logging).
  • Tt is not related to the program's primary function (core business logic).
  • An aspect crosscuts the program's core concerns (for example logging code intertwined with core business logic).
  • Therefore, it can violate the principle of "separation of concerns" which tries to encapsulate unrelated functions. (Code duplication and maintenance nightmare)

In short, aspect is a common concern required throughout the application, and its implementation could lead to repeated boilerplate code and violation of the principle of separation of concerns.

There is a paradigm in the programming world called aspect-oriented programming that aims for modular handling of these common concerns in an application.

Some examples of common "aspects" required throughout the application

  • Logging— Essential for tracking system behavior and troubleshooting
  • Timeouts— Used for preventing long-running code.
  • Retries— Vital for handling flakiness, particularly when accessing third-party APIs.
  • Authentication— Ensure users are authenticated before accessing REST resources, utilizing standard methods like basic authentication or more advanced approaches such as OAuth or single sign-on.

This is where Middleware comes to the rescue. Using middlewares we can compose out-of-the-box middlewares (or our custom middlewares) to address the above-mentioned concerns using ++ and @@ operators as shown below.

The Solution: Middleware in ZIO-HTTP

We cleaned up the code using middleware to address cross-cutting concerns such as authentication, request/response logging, and more. See how we can handle multiple cross-cutting concerns by neatly composing middlewares in a single place:

import zio._
import zio.http._

// compose basic auth, request/response logging, timeouts middlewares
val composedMiddlewares = Middleware.basicAuth("user","pw") ++
Middleware.debug ++
Middleware.timeout(5.seconds)

And then we can attach our composed bundle of middlewares to an Http using @@

 val routes = Routes(
Method.GET / "users" / int("id") ->
handler { (id: Int, req: Request) =>
// core business logic
dbService.lookupUsersById(id).map(Response.json(_.json))
},
Method.GET / "users" ->
handler {
// core business logic
dbService.paginatedUsers(pageNum).map(Response.json(_.json))
}
) @@ composedMiddlewares // attach composedMiddlewares to the routes using @@

Observe how we gained the following benefits by using middlewares

  • Readability: de-cluttering business logic.
  • Modularity: we can manage aspects independently without making changes in 100 places. For example,
    • replacing the logging mechanism from logback to log4j2 will require a change in one place, the logging middleware.
    • replacing the authentication mechanism from OAuth to single sign-on will require changing the auth middleware
  • Testability: we can test our aspects independently.

Building a Custom Middleware

In most cases, we won't need a custom middleware. Instead, we have plenty of built-in middlewares that are ready to use. However, if we have a specific use case, we can create a custom middleware.

To build a custom middleware, we have to implement the Middleware trait, which requires a single method apply to be implemented. The apply method accepts a Routes and returns a new Routes.

For example, assume we want to replace every request path that starts with /api with /v1/api. We can create a custom middleware to achieve this:

val urlRewrite: Middleware[Any] =
new Middleware[Any] {
override def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] =
routes.transform { handler =>
Handler.fromFunctionZIO { request =>
handler(
request.updateURL(url =>
if (url.path.startsWith(Path("/api")))
url.copy(path = Path("/v1") ++ url.path)
else url,
),
)
}
}
}

The above implementation is just for demonstration purposes. In practice, we can use the built-in Middleware.updatePath to achieve the same functionality:

val urlRewrite: Middleware[Any] =
Middleware.updateURL(url =>
if (url.path.startsWith(Path("/api")))
url.copy(path = Path("/v1") ++ url.path)
else url,
)

Built-in Middlewares

In this section we are going to introduce built-in middlewares that are provided by ZIO HTTP. Please note that the Middleware object also inherits many other middlewares from the HandlerAspect, that we will introduce them on the HandlerAspect page.

Access Control Allow Origin (CORS) Middleware

The CORS middleware is used to enable cross-origin resource sharing. It allows the server to specify who can access the resources on the server. The origin is a combination of the protocol, domain, and port of the client. By default, the server does not allow cross-origin requests. What this means is that if a client is hosted on a different domain (or different protocol and port), the server will reject the request. So, if the client is hosted on http://localhost:3000 and the server is hosted on http://localhost:8080, the server will reject the request.

This is where the client may want to create a preflight request to the server to ask for permission to access the resources. This is done by sending a preflight OPTIONS request to the server with the headers Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. If the server determines that the request is allowed, it includes an Access-Control-Allow-Origin header in the response with a value that specifies which origins are permitted to access the resource. The same thing happens with the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers. Now the client can decide whether to send the actual request or not.

To create a CORS middleware, we can use the Middleware.cors constructor. It takes a configuration object of type CorsConfig that specifies the allowed origins, methods, headers, and so on. The CorsConfig object has the following fields:

  1. allowedOrigin— A function that takes the origin of the client and returns allowed origins of type Option[Header.AccessControlAllowOrigin]. By default, the configuration object allows all origins (*).
  2. allowedMethods— The Access-Control-Allow-Methods response header is used in response to a preflight request which includes the Access-Control-Request-Method to indicate which HTTP methods can be used during the actual request. By default, the configuration object allows all methods (*).
  3. allowedHeaders— The Access-Control-Allow-Headers response header is used in response to a preflight request which includes the Access-Control-Request-Headers to indicate which HTTP headers can be used during the actual request. By default, the configuration object allows all headers (*).
  4. allowCredentials— The Access-Control-Allow-Credentials header is sent in response to a preflight request which includes the Access-Control-Request-Headers to indicate whether the actual request can be made using credentials. By default, this configuration is set to Allow.
  5. exposedHeaders— The Access-Control-Expose-Headers header is used in response to a preflight request to indicate which headers can be exposed as part of the response. By default, the configuration object exposes all headers (*).
  6. maxAge— The Access-Control-Max-Age response header is used in response to a preflight request to indicate how long the results of a preflight request can be cached. By default, this configuration is set to None.

In the following example, we are going to serve two HTTP apps. The first app is a backend that serves a JSON response that contains a message. The second app is a frontend that serves an HTML page with a script that fetches the JSON response from the backend. The frontend is hosted on http://localhost:3000 and the backend is hosted on http://localhost:8080. If we try to fetch the JSON response from the frontend, the server will reject the request because the client is hosted on a different origin.

To allow the frontend to access the backend, we need to create a CORS middleware that allows the origin http://localhost:3001. We can do this by creating a CorsConfig object with an allowedOrigin function that returns Some(AccessControlAllowOrigin.Specific(origin)) if the origin is http://localhost:3000. We then attach the CORS middleware to the backend using the @@ operator.

zio-http-example/src/main/scala/example/HelloWorldWithCORS.scala
package example
import zio._

import zio.http.Header.{AccessControlAllowOrigin, Origin}
import zio.http.Middleware.{CorsConfig, cors}
import zio.http._
import zio.http.codec.PathCodec
import zio.http.template._

object HelloWorldWithCORS extends ZIOAppDefault {

val config: CorsConfig =
CorsConfig(
allowedOrigin = {
case origin if origin == Origin.parse("http://localhost:3000").toOption.get =>
Some(AccessControlAllowOrigin.Specific(origin))
case _ => None
},
)

val backend: Routes[Any, Response] =
Routes(
Method.GET / "json" -> handler(Response.json("""{"message": "Hello World!"}""")),
) @@ cors(config)

val frontend: Routes[Any, Response] =
Routes(
Method.GET / PathCodec.empty -> handler(
Response.html(
html(
p("Message: ", output()),
script("""
|// This runs on http://localhost:3000
|fetch("http://localhost:8080/json")
| .then((res) => res.json())
| .then((res) => document.querySelector("output").textContent = res.message);
|""".stripMargin),
),
),
),
)

val frontEndServer = Server.serve(frontend).provide(Server.defaultWithPort(3000))
val backendServer = Server.serve(backend).provide(Server.defaultWithPort(8080))

val run = frontEndServer.zipPar(backendServer)
}

Metrics Middleware

The Middleware.metrics middleware is used to collect metrics about the HTTP requests and responses that are processed by the server. The middleware collects the following metrics:

  • http_requests_total— The total number of HTTP requests that have been processed by the server, using the counter metric type.
  • http_request_duration_seconds— The duration of the HTTP requests in seconds, using the histogram metric type.
  • http_concurrent_requests_total— The total number of concurrent HTTP requests that are being processed by the server, using the gauge metric type.

In the following example, we are going to serve two HTTP apps. One app is a backend that has some routes and the other app is a metrics app that serves the Prometheus metrics. We have attached the Middleware.metrics middleware to the backend using the @@ operator.

In this example we used the Prometheus connector, so we need to add the following dependencies to the build.sbt file:

libraryDependencies ++= Seq(
"dev.zio" %% "zio-metrics-connectors" % "2.3.1",
"dev.zio" %% "zio-metrics-connectors-prometheus" % "2.3.1"
)

To integrate with other metrics systems, please refer to the ZIO Metrics Connectors documentation.

zio-http-example/src/main/scala/example/HelloWorldWithMetrics.scala
package example

import zio._
import zio.metrics._
import zio.metrics.connectors.prometheus.PrometheusPublisher
import zio.metrics.connectors.{MetricsConfig, prometheus}

import zio.http._

object HelloWorldWithMetrics extends ZIOAppDefault {

val backend: Routes[Any, Response] =
Routes(
Method.GET / "json" -> handler((req: Request) =>
ZIO.succeed(Response.json("""{"message": "Hello World!"}""")) @@ Metric
.counter("x_custom_header_total")
.contramap[Any](_ => if (req.headers.contains("X-Custom-Header")) 1L else 0L),
),
Method.GET / "forbidden" -> handler(ZIO.succeed(Response.forbidden)),
) @@ Middleware.metrics()

val metrics: Routes[PrometheusPublisher, Response] =
Routes(
Method.GET / "metrics" -> handler(ZIO.serviceWithZIO[PrometheusPublisher](_.get.map(Response.text))),
)

val run =
Server
.serve(backend ++ metrics)
.provide(
Server.default,
// The prometheus reporting layer
prometheus.prometheusLayer,
prometheus.publisherLayer,
// Interval for polling metrics
ZLayer.succeed(MetricsConfig(1.seconds)),
)
}

Another important thing to note is that the metrics middleware only attaches to the Routes or Routes, so if we want to track some custom metrics particular to a handler, we can use the ZIO#@@ operator to attach a metric of type ZIOAspect to the ZIO effect that is returned by the handler. For example, if we want to track the number of requests that have a custom header X-Custom-Header in the /json route, we can attach a counter metric to the ZIO effect that is returned by the handler using the @@ operator.

Timeout Middleware

The Middleware.timeout middleware is used to set a timeout for the HTTP requests that are processed by the server. If the request takes longer than the specified duration, the server will respond with request timeout status code 408. The middleware takes a Duration parameter that specifies the timeout duration.

routes @@ Middleware.timeout(5.seconds)

Log Annotation Middleware

Using the Middleware.logAnnotate* middleware, we can add more annotations to the logging context. There are several variations of the logAnnotate middleware:

  • logAnnotate(key: => String, value: => String)— Adds a single log annotation with the specified key and value.
  • logAnnotate(logAnnotation: => LogAnnotation, logAnnotations: => LogAnnotation*)— Adds multiple log annotations with the specified log annotations.
  • logAnnotate(logAnnotations: => Set[LogAnnotation])— Adds multiple log annotations with the specified set of log annotations.
  • logAnnotate(fromRequest: Request => Set[LogAnnotation])— Adds log annotations derived from the request.
  • logAnnotateHeaders(headerName: String, headerNames: String*)— Adds log annotations with the names and values of the specified headers.
  • logAnnotateHeaders(header: Header.HeaderType, headers: Header.HeaderType*)— Adds log annotations with the names and values of the specified headers.

Let's write a middleware that adds a correlation ID to the logging context, which is derived from the X-Correlation-ID header of the request. If the header is not present, we generate a random UUID as the correlation ID:

val correlationId =
Middleware.logAnnotate{ req =>
val correlationId =
req.headers.get("X-Correlation-ID").getOrElse(
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run(Random.nextUUID.map(_.toString)).getOrThrow()
}
)

Set(LogAnnotation("correlation-id", correlationId))
}

To see the correlation ID in the logs, we need to place the middleware after the request logging middleware:

routes @@ Middleware.requestLogging() @@ correlationId

Now, if we call one of the routes with the X-Correlation-ID header, we should see the correlation ID in the logs:

timestamp=2024-04-12T08:16:26.034894Z level=INFO thread=#zio-fiber-44 message="Http request served" location=example.HelloWorldWithLogging.backend file=HelloWorldWithLogging.scala line=20 method=GET correlation-id=34fab1bb-eeca-4b4f-975d-12f18e94f2e7 duration_ms=77 url=/json response_size=27 status_code=200 request_size=0

Serving Static Files Middleware

With the Middleware.serveDirectory and Middleware.serveResources middlewares, we can serve static files from a directory or resource directory in the classpath:

zio-http-example/src/main/scala/example/StaticFiles.scala
package example

import zio._

import zio.http._

object StaticFiles extends ZIOAppDefault {

/**
* Creates an HTTP app that only serves static files from resources via
* "/static". For paths other than the resources directory, see
* [[zio.http.Middleware.serveDirectory]].
*/
val routes = Routes.empty @@ Middleware.serveResources(Path.empty / "static")

override def run = Server.serve(routes).provide(Server.default)
}