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
  }
  1. This Middleware type is provided by org.http4s.client.Middleware and is a type alias for Client[F] => Client[F]
  2. Here we are creating a function that takes in the "before" client and transforms it into another client which we return
  3. We run the request on the beforeClient to get access to the response
  4. 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.