Extracting Request Information
There are times where the implementation of your route handlers may require more information from the underlying http4s request. You may want to store the raw request, check the request body against an HMAC value, pass along header values for tracing spans, etc.
When possible, the implementation of things like this should be isolated to middleware and not passed into the route handlers themselves.
However, there are times where this isn't possible. This guide shows you how to implement a middleware that uses IOLocal
to pass the value
of several headers from the request into the smithy4s route handlers.
What is IOLocal?
IOLocal
is a construct that allows for sharing context across the scope of a Fiber
. This means it allows you to get and set some value A
in the IOLocal
.
This value will be accessible across the current Fiber
. As a Fiber
is forked into new fibers, the value of A
is carried over to the new Fiber
.
However, the new Fiber
will not be able to update the value kept on its parent or sibling fibers.
This diagram, adapted from the IOLocal docs, illustrates this well:
Example Implementation
Smithy Spec
For this example, we are going to be working with the following smithy specification (taken from smithy4s repo):
$version: "2"
namespace smithy4s.example.hello
use alloy#simpleRestJson
@simpleRestJson
@tags(["testServiceTag"])
service HelloWorldService {
version: "1.0.0",
// Indicates that all operations in `HelloWorldService`,
// here limited to Hello, can return `GenericServerError`.
errors: [GenericServerError, SpecificServerError],
operations: [Hello]
}
@error("server")
@httpError(500)
structure GenericServerError {
message: String
}
@error("server")
@httpError(599)
structure SpecificServerError {
message: String
}
@http(method: "POST", uri: "/{name}", code: 200)
@tags(["testOperationTag"])
operation Hello {
input: Person,
output: Greeting
}
structure Person {
@httpLabel
@required
name: String,
@httpQuery("town")
town: String
}
structure Greeting {
@required
message: String
}
See our getting started documentation for instructions on how to use this specification to generate scala code.
Service Implementation
Let's start by creating a case class that we will use to hold the value of some headers from our request.
case class RequestInfo(contentType: String, userAgent: String)
This class will give us a spot to place the Content-Type
and User-Agent
headers, respectively. These are just shown
as an example. We could instead pass any other header or part of the request.
From here, we can implement the HelloWorldService
interface that smithy4s generated from the specification above.
import smithy4s.example.hello._
import cats.effect.IO
import cats.effect.IOLocal
final class HelloWorldServiceImpl(requestInfo: IO[RequestInfo]) extends HelloWorldService[IO] {
def hello(name: String, town: Option[String]): IO[Greeting] =
requestInfo.flatMap { reqInfo: RequestInfo =>
IO.println("REQUEST_INFO: " + reqInfo)
.as(Greeting(s"Hello, $name"))
}
}
This is a basic implementation that, in addition to returning a Greeting
, prints the RequestInfo
out to the console.
Note that it is getting the RequestInfo
from the IO[RequestInfo]
that is being passed in as a constructor parameter. This IO
will be created using the same IOLocal
instance is passed to our middleware implementation.
That way, the middleware can set the RequestInfo
value that we are reading here.
Middleware
Below is the middleware implementation. It extracts the Content-Type
and User-Agent
headers and passes them along in the IOLocal
instance it is provided.
import cats.data._
import org.http4s.HttpRoutes
import cats.syntax.all._
import org.http4s.headers.{`Content-Type`, `User-Agent`}
object Middleware {
def withRequestInfo(
routes: HttpRoutes[IO],
local: IOLocal[Option[RequestInfo]]
): HttpRoutes[IO] =
HttpRoutes[IO] { request =>
val requestInfo = for {
contentType <- request.headers.get[`Content-Type`].map(ct => s"${ct.mediaType.mainType}/${ct.mediaType.subType}")
userAgent <- request.headers.get[`User-Agent`].map(_.product.toString)
} yield RequestInfo(
contentType,
userAgent
)
OptionT.liftF(local.set(requestInfo)) *> routes(request)
}
}
Wiring it Together
Now that we have our service implementation and our middleware, we need to combine them to create our application.
import cats.effect.kernel.Resource
object Routes {
private val docs =
smithy4s.http4s.swagger.docs[IO](smithy4s.example.hello.HelloWorldService)
def getAll(local: IOLocal[Option[RequestInfo]]): Resource[IO, HttpRoutes[IO]] = {
val getRequestInfo: IO[RequestInfo] = local.get.flatMap {
case Some(value) => IO.pure(value)
case None => IO.raiseError(new IllegalAccessException("Tried to access the value outside of the lifecycle of an http request"))
}
smithy4s.http4s.SimpleRestJsonBuilder
.routes(new HelloWorldServiceImpl(getRequestInfo))
.resource
.map { routes =>
Middleware.withRequestInfo(routes <+> docs, local)
}
}
}
Here we are creating our routes (with swagger docs) and passing them to our middleware. The result of applying the Middleware is our final routes.
We also turn our IOLocal
into an IO[RequestInfo]
for the HelloWorldServiceImpl
. We do this because the service implementation
does not need to know that the value is coming from an IOLocal
or that the value is optional (since it will always be populated by
our middleware). Doing it this way allows us to reduce the complexity in the service implementation.
Finally, we create our main class and construct the http4s server.
import cats.effect.IOApp
import com.comcast.ip4s._
import org.http4s.ember.server.EmberServerBuilder
object Main extends IOApp.Simple {
def run: IO[Unit] = IOLocal(Option.empty[RequestInfo]).flatMap { local =>
Routes
.getAll(local)
.flatMap { routes =>
EmberServerBuilder
.default[IO]
.withHost(host"localhost")
.withPort(port"9000")
.withHttpApp(routes.orNotFound)
.build
}
.useForever
}
}
Notice that we create the IOLocal
with Option.empty[RequestInfo]
. This is because IOLocal
requires a value
to be constructed. However, this value will never be used in practice. This is because we are setting the value in
the middleware on every request prior to the request being handled by our HelloWorldService
implementation.
Testing it out
With the above in place, we can run our application and test it out.
curl -X 'POST' \
'http://localhost:9000/Test' \
-H 'User-Agent: Chrome/103.0.0.0' \
-H 'Content-Type: application/json'
Running this curl
will cause the following to print out to the console:
REQUEST_INFO: RequestInfo(Some(application/json),Some(Chrome/103.0.0.0))
Alternative Methods
If you are working with a tagless F[_]
rather than IO
directly, you may want to check out Chris Davenport's implementation
of FiberLocal.
You can also use Kleisli
to accomplish the same things we showed in this tutorial and you are welcome to do so if you prefer that.
We opted to show an example with IOLocal
since it allows users to use IO
directly, without monad transformers, which many
users will be more comfortable with. Similarly, you could use Local
from cats-mtl or probably a variety of other approaches.
We recommend you use whatever fits the best with your current application design.