Http4s Client Middleware
A client middleware is something that you can add on to a Client to extend its functionality. Some examples of this would be:
- Logging middleware that logs response status codes/headers/etc
- Tracing middleware that wires in functionality for Open Tracing or similar
- Authentication middleware that would automatically add an authorization header or cookie to all outgoing requests
Http4s comes with some middleware already created that you can use for common use cases. You can see more about this in their documentation.
However, there are some cases where you want to do something custom that isn't provided by one of the Http4s-provided middlewares. Luckily, Http4s makes creating middleware quite straightforward. This is because a Client
in Http4s is really just a function from Request[F]
to Resource[F, Response[F]]
. Additionally, a middleware for a client in Http4s is a function from Client[F] => Client[F]
. In other words, we are making a function that takes a function as an argument, modifies the function, and returns it. Simplified a bit, it would look like (Request => Response) => (Request => Response)
. Here each (Request => Response)
is a Client
and the middle =>
is the middleware.
Example — Logging Middleware
Let's show an example by creating a logging middleware that will log our response codes.
val logMiddleware: Middleware[IO] = // 1
(beforeClient: Client[IO]) => Client { request => // 2
beforeClient.run(request) // 3
.evalTap(response => IO.println(response.code)) // 4
}
- This
Middleware
type is provided byorg.http4s.client.Middleware
and is a type alias forClient[F] => Client[F]
- Here we are creating a function that takes in the "before" client and transforms it into another client which we return
- We run the request on the
beforeClient
to get access to the response - We are using
IO.println
for simplicity rather than a logging library of some kind
We can now apply this middleware like so:
import cats.effect.IOApp
import cats.effect.IO
import org.http4s.client.*
import org.http4s.ember.client.EmberClientBuilder
import cats.effect.kernel.Resource
object Main extends IOApp.Simple {
val run: IO[Unit] = (for {
client <- EmberClientBuilder.default[IO].build.map(logMiddleware(_))
_ <- Resource.eval(client.get("https://example.com")(_ => IO.unit))
} yield ()).use_
}
// Outputs: 'RESPONSE CODE: 200'
Composition
Because a Middleware[IO]
is really just a type alias for a function Client[IO] => Client[IO]
, we can compose multiple instances of Middleware
just like we would any function in Scala. For example, we can use the andThen
method to combine:
val mid: Middleware[IO] = middlewareOne andThen middlewareTwo andThen middlewareThree
// OR you could use List(one, two, three).reduce(_ andThen _)
val client = mid.apply(client)
Note that the order in which these are executed will be perhaps not entirely what you'd expect at first glance. For example, if we have the following middleware that sends a log before and after running the request:
def logMiddleware(num: Int): Middleware[IO] = (beforeClient: Client[IO]) =>
Client { request =>
Resource.eval(IO.println(s"BEFORE $num")) *>
beforeClient.run(request) // run the request
.evalTap(_ => IO.println(s"AFTER $num"))
}
And then we compose multiple of them together:
val middlewares: Middleware[IO] =
logMiddleware(1) andThen logMiddleware(2) andThen logMiddleware(3)
When we apply this to a client and run a request, it will print out:
BEFORE 3
BEFORE 2
BEFORE 1
AFTER 1
AFTER 2
AFTER 3
This is because the outermost middleware will be hit first. This is number 3 in this case since it was added last. From there, when number 3 runs its request, it will call the next layer, middleware 2. This will continue until it gets down to the base client. At this point, the HTTP request itself is run and the response to that request is propagated back up through the chain of middlewares. This is why we see the execution go from outside to inside and then back to the outside (3 -> 2 -> 1 -> base -> 1 -> 2 -> 3).
If instead of using andThen
we use compose
to combine our middlewares:
val middlewares: Middleware[IO] =
logMiddleware(1) compose logMiddleware(2) compose logMiddleware(3)
Then we will get the execution happening in the opposite order where we start with the first middleware and work our way to the last:
BEFORE 1
BEFORE 2
BEFORE 3
AFTER 3
AFTER 2
AFTER 1
This switch in ordering is not unique to middleware, but rather how functions in Scala combine when using andThen
versus compose
. f andThen g
means to execute f
before g
which means g(f())
. f compose g
means to execute f
after g
which means f(g())
.
Sometimes the order in which middleware is executed doesn't matter, but sometimes it does, so you'll want to make sure you use the proper approach for your use case.
Conclusion
This is meant to be a short and relatively to-the-point guide showing how to create custom client middleware for Http4s clients. If you are looking to do something more advanced inside your middleware, a good place to look for inspiration would be the client middleware implementations provided by Http4s. Beyond that, start simple and write tests to make sure you’re on the right track.