The http4s DSL
Recall from earlier that an HttpRoutes[F]
is just a type alias for
Kleisli[OptionT[F, *], Request[F], Response[F]]
. This provides a minimal
foundation for declaring services and executing them on blaze or a
servlet container. While this foundation is composable, it is not
highly productive. Most service authors will seek a higher level DSL.
Add the http4s-dsl to your build
One option is the http4s-dsl. It is officially supported by the http4s team, but kept separate from core in order to encourage multiple approaches for different needs.
This tutorial assumes that http4s-dsl is on your classpath. Add the following to your build.sbt:
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-dsl" % http4sVersion,
)
All we need is a REPL to follow along at home:
$ sbt console
The Simplest Service
We'll need the following imports to get started:
import cats.effect._
import cats.syntax.all._
import org.http4s._, org.http4s.dsl.io._, org.http4s.implicits._
If you're in a REPL, we also need a runtime:
import cats.effect.unsafe.IORuntime
implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
The central concept of http4s-dsl is pattern matching. An
HttpRoutes[F]
is declared as a simple series of case statements. Each
case statement attempts to match and optionally extract from an
incoming Request[F]
. The code associated with the first matching case
is used to generate a F[Response[F]]
.
The simplest case statement matches all requests without extracting
anything. The right hand side of the request must return a
F[Response[F]]
.
In the following we use cats.effect.IO
as the effect type F
.
val service = HttpRoutes.of[IO] {
case _ =>
IO(Response(Status.Ok))
}
Testing the Service
One beautiful thing about the HttpRoutes[F]
model is that we don't
need a server to test our route. We can construct our own request
and experiment directly in the REPL.
val getRoot = Request[IO](Method.GET, uri"/")
val serviceIO = service.orNotFound.run(getRoot)
Where is our Response[F]
? It hasn't been created yet. We wrapped it
in an IO
. In a real service, generating a Response[F]
is likely to
be an asynchronous operation with side effects, such as invoking
another web service or querying a database, or maybe both. Operating
in a F
gives us control over the sequencing of operations and
lets us reason about our code like good functional programmers. It is
the HttpRoutes[F]
's job to describe the task, and the server's job to
run it.
But here in the REPL, it's up to us to run it:
val response = serviceIO.unsafeRunSync()
// response: Response[[A]IO[A]] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(),
// = Stream(..),
// = org.typelevel.vault.Vault@6b73315f
// )
Cool.
Generating Responses
We'll circle back to more sophisticated pattern matching of requests,
but it will be a tedious affair until we learn a more succinct way of
generating F[Response]
s.
Status codes
http4s-dsl provides a shortcut to create an F[Response]
by
applying a status code:
val okIo: IO[Response[IO]] = Ok()
This simple Ok()
expression succinctly says what we mean in a
service:
HttpRoutes.of[IO] {
case _ => Ok()
}.orNotFound.run(getRoot).unsafeRunSync()
// res0: Response[[A]IO[A]] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Length: 0),
// = Stream(..),
// = org.typelevel.vault.Vault@6f3dae95
// )
This syntax works for other status codes as well. In our example, we
don't return a body, so a 204 No Content
would be a more appropriate
response:
HttpRoutes.of[IO] {
case _ => NoContent()
}.orNotFound.run(getRoot).unsafeRunSync()
// res1: Response[[A]IO[A]] = (
// = Status(code = 204),
// = HttpVersion(major = 1, minor = 1),
// = Headers(),
// = Stream(..),
// = org.typelevel.vault.Vault@73279bb1
// )
Some other examples are:
HttpRoutes.of[IO] {
case _ => Conflict()
}.orNotFound.run(getRoot).unsafeRunSync()
// res2: Response[[A]IO[A]] = (
// = Status(code = 409),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Length: 0),
// = Stream(..),
// = org.typelevel.vault.Vault@4819b372
// )
HttpRoutes.of[IO] {
case _ => Created()
}.orNotFound.run(getRoot).unsafeRunSync()
// res3: Response[[A]IO[A]] = (
// = Status(code = 201),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Length: 0),
// = Stream(..),
// = org.typelevel.vault.Vault@78876b88
// )
HttpRoutes.of[IO] {
case _ => Forbidden()
}.orNotFound.run(getRoot).unsafeRunSync()
// res4: Response[[A]IO[A]] = (
// = Status(code = 403),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Length: 0),
// = Stream(..),
// = org.typelevel.vault.Vault@2452ce26
// )
Headers
http4s adds a minimum set of headers depending on the response, e.g:
Ok("Ok response.").unsafeRunSync().headers
// res5: Headers = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 12)
Extra headers can be added using putHeaders
, for example to specify cache policies:
import org.http4s.headers.`Cache-Control`
import org.http4s.CacheDirective.`no-cache`
import cats.data.NonEmptyList
Ok("Ok response.", `Cache-Control`(NonEmptyList(`no-cache`(), Nil)))
.unsafeRunSync().headers
// res6: Headers = Headers(Content-Type: text/plain; charset=UTF-8, Cache-Control: no-cache, Content-Length: 12)
http4s defines all the well known headers directly, but sometimes you need to
define custom headers, typically prefixed by an X-
. In simple cases you can
construct a Header
instance by hand:
Ok("Ok response.", "X-Auth-Token" -> "value")
.unsafeRunSync().headers
// res7: Headers = Headers(Content-Type: text/plain; charset=UTF-8, X-Auth-Token: value, Content-Length: 12)
Cookies
http4s has special support for Cookie headers using the Cookie
type to add
and invalidate cookies. Adding a cookie will generate the correct Set-Cookie
header:
Ok("Ok response.").map(_.addCookie(ResponseCookie("foo", "bar")))
.unsafeRunSync().headers
// res8: Headers = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 12, Set-Cookie: foo=bar)
Cookie
can be further customized to set, e.g., expiration, the secure flag, httpOnly, flag, etc
val cookieResp = {
for {
resp <- Ok("Ok response.")
now <- HttpDate.current[IO]
} yield resp.addCookie(ResponseCookie("foo", "bar",
expires = Some(now), httpOnly = true, secure = true))
}
cookieResp.unsafeRunSync().headers
// res9: Headers = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 12, Set-Cookie: foo=bar; Expires=Tue, 05 Nov 2024 10:13:18 GMT; Secure; HttpOnly)
To request a cookie to be removed on the client, you need to set the cookie value
to empty. http4s can do that with removeCookie
:
Ok("Ok response.").map(_.removeCookie("foo")).unsafeRunSync().headers
// res10: Headers = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 12, Set-Cookie: foo=; Expires=Thu, 01 Jan 1970 00:00:00 GMT)
Responding with a Body
Simple Bodies
Most status codes take an argument as a body. In http4s, Request[F]
and Response[F]
bodies are represented as a
fs2.Stream[F, Byte]
. It's also considered good
HTTP manners to provide a Content-Type
and, where known in advance,
Content-Length
header in one's responses.
All of this hassle is neatly handled by http4s' EntityEncoders.
We'll cover these in more depth in another tutorial. The important point
for now is that a response body can be generated for any type with an
implicit EntityEncoder
in scope. http4s provides several out of the
box:
Ok("Received request.").unsafeRunSync()
// res11: Response[IO] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 17),
// = Stream(..),
// = org.typelevel.vault.Vault@ca6f38d
// )
import java.nio.charset.StandardCharsets.UTF_8
Ok("binary".getBytes(UTF_8)).unsafeRunSync()
// res12: Response[IO] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: application/octet-stream, Content-Length: 6),
// = Stream(..),
// = org.typelevel.vault.Vault@4677b35a
// )
Per the HTTP specification, some status codes don't support a body. http4s prevents such nonsense at compile time:
NoContent("does not compile")
// error: no arguments allowed for nullary method apply: ()(implicit F: cats.Applicative[cats.effect.IO]): cats.effect.IO[org.http4s.Response[cats.effect.IO]] in trait EmptyResponseGenerator
// NoContent("does not compile")
// ^^^^^^^^^^^^^^^^^^
Asynchronous Responses
While http4s prefers F[_]: Async
, you may be working with libraries that
use standard library Future
s. Some relevant imports:
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
You can respond with a Future
of any type that has an
EntityEncoder
by lifting it into IO
or any F[_]
that suspends future.
Note: unlike IO
, wrapping a side effect in Future
does not
suspend it, and the resulting expression would still be side
effectful, unless we wrap it in IO
:
IO.fromFuture
ensures that the suspended future is shifted to the correct
thread pool.
val ioFuture = Ok(IO.fromFuture(IO(Future {
println("I run when the future is constructed.")
"Greetings from the future!"
})))
ioFuture.unsafeRunSync()
// I run when the future is constructed.
// res14: Response[IO] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 26),
// = Stream(..),
// = org.typelevel.vault.Vault@3d3dc6b7
// )
As good functional programmers who like to delay our side effects, we
of course prefer to operate in F
s:
val io = Ok(IO {
println("I run when the IO is run.")
"Mission accomplished!"
})
io.unsafeRunSync()
// I run when the IO is run.
// res15: Response[IO] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 21),
// = Stream(..),
// = org.typelevel.vault.Vault@3b384e10
// )
Note that in both cases, a Content-Length
header is calculated.
http4s waits for the Future
or F
to complete before wrapping it
in its HTTP envelope, and thus has what it needs to calculate a
Content-Length
.
Streaming Bodies
Streaming bodies are supported by returning a fs2.Stream
.
Like IO
, the stream may be of any type that has an
EntityEncoder
.
An intro to Stream
is out of scope, but we can glimpse the
power here. This stream emits the elapsed time every 100 milliseconds
for one second:
import fs2.Stream
import scala.concurrent.duration._
val drip: Stream[IO, String] =
Stream.awakeEvery[IO](100.millis).map(_.toString).take(10)
We can see it for ourselves in the REPL:
val dripOutIO = drip
.through(fs2.text.lines)
.evalMap(s => { IO{println(s); s} })
.compile
.drain
// dripOutIO: IO[Unit] = Uncancelable(
// body = cats.effect.IO$$$Lambda$22841/0x000000080573b040@1bf5977,
// event = cats.effect.tracing.TracingEvent$StackTrace
// )
dripOutIO.unsafeRunSync()
// 101694397 nanoseconds200745601 nanoseconds300871444 nanoseconds400682387 nanoseconds500673804 nanoseconds600727248 nanoseconds700706923 nanoseconds800766832 nanoseconds900671045 nanoseconds1000649331 nanoseconds
When wrapped in a Response[F]
, http4s will flush each chunk of a
Stream
as they are emitted. Note that a stream's length can't
generally be anticipated before it runs, so this triggers chunked
transfer encoding:
Ok(drip)
// res17: IO[Response[IO]] = Pure(
// value = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Transfer-Encoding: chunked),
// = Stream(..),
// = org.typelevel.vault.Vault@34d4cdb2
// )
// )
Matching and Extracting Requests
A Request
is a regular case class
- you can destructure it to extract its
values. By extension, you can also match/case
it with different possible
destructurings. To build these different extractors, you can make use of the
DSL.
The ->
object
More often, you extract the Request
into a HTTP Method
and path
info via the ->
object. On the left side is the method, and on the
right side, the path info. The following matches a request to GET
/hello
:
HttpRoutes.of[IO] {
case GET -> Root / "hello" => Ok("hello")
}
Methods such as GET
are typically found in org.http4s.Method
, but are imported automatically as part of the DSL.
Path Info
Path matching is done on the request's pathInfo
. Path info is the
request's URI's path after the following:
- the mount point of the service
- the prefix, if the service is composed with a
Router
- the prefix, if the service is rewritten with
TranslateUri
Matching on request.pathInfo
instead of request.uri.path
allows
multiple services to be composed without rewriting all the path
matchers.
Matching Paths
A request to the root of the service is matched with the Root
extractor. Root
consumes the leading slash of the path info. The
following matches requests to GET /
:
HttpRoutes.of[IO] {
case GET -> Root => Ok("root")
}
We usually match paths in a left-associative manner with Root
and
/
. Each "/"
after the initial slash delimits a path segment, and
is represented in the DSL with the '/' extractor. Segments can be
matched as literals or made available through standard Scala pattern
matching. For example, the following service responds with "Hello,
Alice!" to GET /hello/Alice
:
HttpRoutes.of[IO] {
case GET -> Root / "hello" / name => Ok(s"Hello, ${name}!")
}
The above assumes only one path segment after "hello"
, and would not
match GET /hello/Alice/Bob
. To match to an arbitrary depth, we need
a right-associative /:
extractor. In this case, there is no Root
,
and the final pattern is a Path
of the remaining segments. This would
say "Hello, Alice and Bob!"
HttpRoutes.of[IO] {
case GET -> "hello" /: rest => Ok(s"""Hello, ${rest.segments.mkString(" and ")}!""")
}
To match a file extension on a segment, use the ~
extractor:
HttpRoutes.of[IO] {
case GET -> Root / file ~ "json" => Ok(s"""{"response": "You asked for $file"}""")
}
Handling Path Parameters
Path params can be extracted and converted to a specific type but are
String
s by default. There are numeric extractors provided in the form
of IntVar
and LongVar
, as well as UUIDVar
extractor for java.util.UUID
.
def getUserName(userId: Int): IO[String] = ???
val usersService = HttpRoutes.of[IO] {
case GET -> Root / "users" / IntVar(userId) =>
Ok(getUserName(userId))
}
If you want to extract a variable of type T
, you can provide a custom extractor
object which implements def unapply(str: String): Option[T]
, similar to the way
in which IntVar
does it.
import java.time.LocalDate
import scala.util.Try
object LocalDateVar {
def unapply(str: String): Option[LocalDate] = {
if (!str.isEmpty)
Try(LocalDate.parse(str)).toOption
else
None
}
}
def getTemperatureForecast(date: LocalDate): IO[Double] = IO(42.23)
val dailyWeatherService = HttpRoutes.of[IO] {
case GET -> Root / "weather" / "temperature" / LocalDateVar(localDate) =>
Ok(getTemperatureForecast(localDate)
.map(s"The temperature on $localDate will be: " + _))
}
val request = Request[IO](Method.GET, uri"/weather/temperature/2016-11-05")
dailyWeatherService.orNotFound(request).unsafeRunSync()
// res23: Response[[A]IO[A]] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 44),
// = Stream(..),
// = org.typelevel.vault.Vault@479993b9
// )
Handling Matrix Path Parameters
Matrix path parameters can be extracted using MatrixVar
.
In following example, we extract the first
and last
matrix path parameters.
By default, matrix path parameters are extracted as String
s.
import org.http4s.dsl.impl.MatrixVar
object FullNameExtractor extends MatrixVar("name", List("first", "last"))
val greetingService = HttpRoutes.of[IO] {
case GET -> Root / "hello" / FullNameExtractor(first, last) / "greeting" =>
Ok(s"Hello, $first $last.")
}
greetingService
.orNotFound(Request[IO](
method = Method.GET,
uri = uri"/hello/name;first=john;last=doe/greeting"
)).unsafeRunSync()
// res24: Response[[A]IO[A]] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 16),
// = Stream(..),
// = org.typelevel.vault.Vault@56f15d09
// )
Like standard path parameters, matrix path parameters can be extracted as numeric types using IntVar
or LongVar
.
object FullNameAndIDExtractor extends MatrixVar("name", List("first", "last", "id"))
val greetingWithIdService = HttpRoutes.of[IO] {
case GET -> Root / "hello" / FullNameAndIDExtractor(first, last, IntVar(id)) / "greeting" =>
Ok(s"Hello, $first $last. Your User ID is $id.")
}
greetingWithIdService
.orNotFound(Request[IO](
method = Method.GET,
uri = uri"/hello/name;first=john;last=doe;id=123/greeting"
)).unsafeRunSync()
// res25: Response[[A]IO[A]] = (
// = Status(code = 200),
// = HttpVersion(major = 1, minor = 1),
// = Headers(Content-Type: text/plain; charset=UTF-8, Content-Length: 37),
// = Stream(..),
// = org.typelevel.vault.Vault@69b13bde
// )
Handling Query Parameters
A query parameter needs to have a QueryParamDecoderMatcher
provided to
extract it. In order for the QueryParamDecoderMatcher
to work there needs to
be an implicit QueryParamDecoder[T]
in scope. QueryParamDecoder
s for simple
types can be found in the QueryParamDecoder
object. There are also
QueryParamDecoderMatcher
s available which can be used to
return optional or validated parameter values.
In the example below we're finding query params named country
and year
and
then parsing them as a String
and java.time.Year
.
import java.time.Year
object CountryQueryParamMatcher extends QueryParamDecoderMatcher[String]("country")
implicit val yearQueryParamDecoder: QueryParamDecoder[Year] =
QueryParamDecoder[Int].map(Year.of)
// yearQueryParamDecoder: QueryParamDecoder[Year] = org.http4s.QueryParamDecoder$$anon$7@3f391efb
object YearQueryParamMatcher extends QueryParamDecoderMatcher[Year]("year")
def getAverageTemperatureForCountryAndYear(country: String, year: Year): IO[Double] = ???
val averageTemperatureService = HttpRoutes.of[IO] {
case GET -> Root / "weather" / "temperature" :? CountryQueryParamMatcher(country) +& YearQueryParamMatcher(year) =>
Ok(getAverageTemperatureForCountryAndYear(country, year)
.map(s"Average temperature for $country in $year was: " + _))
}
// averageTemperatureService: HttpRoutes[IO] = Kleisli(
// run = org.http4s.HttpRoutes$$$Lambda$22348/0x00000008054a7040@27a2f475
// )
To support a QueryParamDecoderMatcher[Instant]
, consider QueryParamCodec#instantQueryParamCodec
. That
outputs a QueryParamCodec[Instant]
, which offers both a QueryParamEncoder[Instant]
and QueryParamDecoder[Instant]
.
import java.time.Instant
import java.time.format.DateTimeFormatter
implicit val isoInstantCodec: QueryParamCodec[Instant] =
QueryParamCodec.instantQueryParamCodec(DateTimeFormatter.ISO_INSTANT)
object IsoInstantParamMatcher extends QueryParamDecoderMatcher[Instant]("timestamp")
Optional Query Parameters
To accept an optional query parameter a OptionalQueryParamDecoderMatcher
can be used.
import java.time.Year
implicit val yearQueryParamDecoder: QueryParamDecoder[Year] =
QueryParamDecoder[Int].map(Year.of)
// yearQueryParamDecoder: QueryParamDecoder[Year] = org.http4s.QueryParamDecoder$$anon$7@34c58700
object OptionalYearQueryParamMatcher
extends OptionalQueryParamDecoderMatcher[Year]("year")
def getAverageTemperatureForCurrentYear: IO[String] = ???
def getAverageTemperatureForYear(y: Year): IO[String] = ???
val routes = HttpRoutes.of[IO] {
case GET -> Root / "temperature" :? OptionalYearQueryParamMatcher(maybeYear) =>
maybeYear match {
case None =>
Ok(getAverageTemperatureForCurrentYear)
case Some(year) =>
Ok(getAverageTemperatureForYear(year))
}
}
// routes: HttpRoutes[IO] = Kleisli(
// run = org.http4s.HttpRoutes$$$Lambda$22348/0x00000008054a7040@6ed6b23c
// )
Optional Multiple Query Paramters
To accept multiple query parameters that are also optional, a OptionalMultiQueryParamDecoderMatcher
can be used.
object OptionalMultiColorQueryParam
extends OptionalMultiQueryParamDecoderMatcher[String]("maybeColors")
def getProductsOfMaybeColors(maybeColors: List[String]): IO[String] = ???
val routes = HttpRoutes.of[IO] {
case GET -> Root / "products" :? OptionalMultiColorQueryParam(maybeColors) =>
val _: cats.data.ValidatedNel[org.http4s.ParseFailure, List[String]] = maybeColors
maybeColors match {
case cats.data.Validated.Invalid(e) =>
BadRequest(s"Parse Error(s): ${e.toList.map(_.message).mkString(", ")}")
case cats.data.Validated.Valid(a) => Ok(getProductsOfMaybeColors(a))
}
}
// routes: HttpRoutes[IO] = Kleisli(
// run = org.http4s.HttpRoutes$$$Lambda$22348/0x00000008054a7040@4cf171dc
// )
Missing Required Query Parameters
A request with a missing required query parameter will fall through to the following case
statements and may eventually return a 404. To provide contextual error handling, optional query parameters or fallback routes can be used.
Invalid Query Parameter Handling
To validate query parsing you can use ValidatingQueryParamDecoderMatcher
which returns a ParseFailure
if the parameter cannot be decoded. Be careful not to return the raw invalid value in a BadRequest
because it could be used for Cross Site Scripting attacks.
implicit val yearQueryParamDecoder: QueryParamDecoder[Year] =
QueryParamDecoder[Int]
.emap(i => Try(Year.of(i))
.toEither
.leftMap(t => ParseFailure(t.getMessage, t.getMessage)))
// yearQueryParamDecoder: QueryParamDecoder[Year] = org.http4s.QueryParamDecoder$$anon$9@2b906658
object YearQueryParamMatcher extends ValidatingQueryParamDecoderMatcher[Year]("year")
val routes = HttpRoutes.of[IO] {
case GET -> Root / "temperature" :? YearQueryParamMatcher(yearValidated) =>
yearValidated.fold(
parseFailures => BadRequest("unable to parse argument year"),
year => Ok(getAverageTemperatureForYear(year))
)
}
// routes: HttpRoutes[IO] = Kleisli(
// run = org.http4s.HttpRoutes$$$Lambda$22348/0x00000008054a7040@2b8db9c9
// )
Optional Invalid Query Parameter Handling
Consider OptionalValidatingQueryParamDecoderMatcher[A]
given the power that
Option[cats.data.ValidatedNel[org.http4s.ParseFailure, A]]
provides.
object LongParamMatcher extends OptionalValidatingQueryParamDecoderMatcher[Long]("long")
val routes = HttpRoutes.of[IO] {
case GET -> Root / "number" :? LongParamMatcher(maybeNumber) =>
val _: Option[cats.data.ValidatedNel[org.http4s.ParseFailure, Long]] = maybeNumber
maybeNumber match {
case Some(n) =>
n.fold(
parseFailures => BadRequest("unable to parse argument 'long'"),
year => Ok(n.toString)
)
case None => BadRequest("missing number")
}
}
// routes: HttpRoutes[IO] = Kleisli(
// run = org.http4s.HttpRoutes$$$Lambda$22348/0x00000008054a7040@7eaef71f
// )