--
Finch's RequestReader
is an implementation of the reader monad, a common design pattern in functional programming.
A RequestReader[A]
is just a wrapper for a function Request => Future[A]
that provides map
and flatMap
implementations, together with a few other combinators.
The purpose of the reader monad is to avoid repetitive boilerplate when composing operations that read from a common environment of some kind. For example, we might find ourselves writing something like this when processing an HTTP request in a Finagle application:
import io.finch._
import com.twitter.finagle.http.{Request, Response}
def param(req: Request)(key: String): Option[String] =
req.params.get(key) orElse {
??? // try to get parameter from multipart form
}
def doSomethingWithRequest(req: Request): Option[Result] =
for {
foo <- param(req)("foo")
bar <- param(req)("bar")
baz <- req.headerMap.get("baz")
qux <- req.headerMap.get("qux")
content <- Some(req.contentString)
} yield Result(...)
This works, but it's often useful to be able to make each of these reading operations independent and composable pieces
(not expressions built around a req
variable that happens to be in scope). The reader monad makes this easy. For example,
we could rewrite our doSomethingWithRequest
operation as follows using Finch's RequestReader
:
val doSomethingWithRequest: RequestReader[Result] =
for {
foo <- param("foo")
bar <- param("bar")
baz <- header("baz")
qux <- header("qux")
content <- body
} yield Result(...)
We could then "run" the request reader by passing it a Request
:
val result: Future[Result] = doSomethingWithRequest(myReq)
What's happening here is that we're building up a large Request => A
function out of smaller Request => A
pieces. param("foo")
, header("baz")
and body
, for example, are all values of type RequestReader[String]
, where
param
, header
, and body
are generally useful readers that are provided by Finch.
Note that the result of running a request reader is a value in a future (not an Option
, as in our original example).
This makes it possible to chain readers together with Finagle services in a single for
-comprehension. This can be
extremely useful when a service should fetch and validate the request parameters before doing the real job, and not do
the job at all if the parameters are not valid. A request reader can just return a failed future and no further
operations in the for
-comprehension will be performed.
A typical RequestReader
might look like this:
import io.finch.request._
case class User(name: String, age: Int, city: String)
val user: RequestReader[User] = (
param("name") ::
param("age").as[Int].shouldNot(beLessThan(18)) ::
paramOption("city").withDefault("Novosibirsk")
).as[User]
A RequestReader
is responsible for the following typical tasks in request
processing:
- reading parameters, header, cookies or the body of the request (see Base Readers).
- declaring these artifacts as either required or optional (see Required and Optional Readers).
- converting
String
-based and composite inputs to other types with theas[A]
method (see Type Conversion). - validating one or more readers with
should
orshouldNot
(see Validation). - combining multiple readers with the
::
combinator method (see Combining and Reusing Readers).
The RequestReader
API is fairly simple. It allows the user to apply the reader to a request instance with apply
, to
transform the reader with map
(or ~>
), to transform the reader in a RequestReader
or Future
context (flatMap
and embedFlatMap
respectively), to combine it with other readers with the ::
combinator, and to validate it with
should
or shouldNot
:
trait RequestReader[A] {
def apply(req: Request): Future[A]
def map[B](fn: A => B): RequestReader[B]
def flatMap[B](fn: A => RequestReader[B]): RequestReader[B]
def ::[B](that: RequestReader[B]): RequestReader[A :: B :: HNil]
def should(rule: String)(predicate: A => Boolean): RequestReader[A]
def shouldNot(rule: String)(predicate: A => Boolean): RequestReader[A]
def should(rule: ValidationRule[A]): RequestReader[A]
def shouldNot(rule: ValidationRule[A]): RequestReader[A]
}
In addition there are implicit as[A]
methods available for type conversion on String
-based and composite
(HList
-based) readers. See Type Conversion for more details.
// for all `RequestReader[String]`
def as[A](implicit decode: DecodeRequest[A], tag: ClassTag[A]): RequestReader[A]
// for all `RequestReader[Option[String]]`
def as[A](implicit decode: DecodeRequest[A], tag: ClassTag[A]): RequestReader[Option[A]]
// for all `RequestReader[Seq[String]]`
def as[A](implicit decode: DecodeRequest[A], tag: ClassTag[A]): RequestReader[Seq[A]]
// for all `RequestReader[L <: HList]`
def as[A](implicit gen: Generic.Aux[A, L]): RequestReader[A]
The following sections cover all these features in more detail. All sample code assumes that you have imported
io.finch.request._
.
Finally, RequestReader
s that return Option
values have a couple of additional useful methods:
withDefault(value: A)
and orElse(alternative: Option[A])
.
Finch provides a set of base readers for extracting parameters, headers, cookies or the body from the request. The
column for the result type specifies the type parameter of the resulting reader (e.g. Option[String]
means the reader
is a RequestReader[Option[String]]
).
Request Item | Reader Type | Result Type |
---|---|---|
Parameter | param(name) /paramOption(name) |
String /Option[String] |
Multi-Value Parameters | paramsNonEmpty(name) /params(name) |
Seq[String] /Seq[String] |
Header | header(name) /headerOption(name) |
String /Option[String] |
Cookie | cookie(name) /cookieOption(name) |
Cookie /Option[Cookie] |
Text Body | body /bodyOption |
String /Option[String] |
Binary Body | binaryBody /binaryBodyOption |
Array[Byte] /Option[Array[Byte]] |
File Upload | fileUpload /fileUploadOption |
FileUpload /Option[FileUpload] |
As you can see in the table above, the six base readers all come in two flavors, allowing one to declare a request item as either required or optional.
- An
x
reader fails with aNotPresent
exception if the item is not found in the request - An
xOption
reader always succeeds, producing aNone
if the item is not found in the request - If you apply type conversions or validations to an optional item, the behaviour is as follows:
- If the result is
None
, all type conversions and validations are skipped and the reader succeeds with aNone
result - If the result is non-empty, all type conversions and validations have to succeed or otherwise the reader will fail
- If the result is
The paramsNonEmpty
and params
readers read multi-value parameters in the following way:
- In case of multiple occurrences of the same parameter in the URL, the values are combined into a single
Seq[String]
- If any of the values is a comma-separated list, it will be split into
Seq[String]
Thus, the following HTTP params a=1,2,3&b=4&b=5
might be fetched with the paramsNonEmpty
reader like this:
// asTuple method is available on HList-based readers
val reader: RequestReader[(Seq[Int], Seq[Int])] = (
paramsNonEmpty("a").as[Int] ::
paramsNonEmpty("b").as[Int]
).asTuple
val (a, b): (Seq[Int], Seq[Int]) = reader(request)
// a = Seq(1, 2, 3)
// b = Seq(4, 5)
In most cases you will combine several of the built-in base readers to compose new readers. For the rare cases where you
want to create a new reader type yourself, the RequestReader
companion object comes with a range of convenient factory
methods:
// Creates a new reader that always succeeds, producing the specified value.
def value[A](value: A): RequestReader[A]
// Creates a new reader that always fails, producing the specified exception.
def exception[A](exc: Throwable): RequestReader[A]
// Creates a new reader that always produces the specified value.
def const[A](value: Future[A]): RequestReader[A]
// Creates a new reader that reads the result from the request.
def apply[A](f: Request => A): RequestReader[A]
The exceptions from a request reader might be handled just like other failed futures in Finagle:
val user: Future[Json] = service(...) handle {
case NotFound(ParamItem(param)) =>
Json.obj("error" -> "param_not_found", "param" -> param)
case NotValid(ParamItem(param), rule) =>
Json.obj("error" -> "validation_failed", "param" -> param, "rule" -> rule)
}
All the exceptions thrown by RequestReader
are case classes. Therefore pattern matching may be used to handle them.
These are all error types produced by Finch (note that all extend RequestError
):
// when multiple request items were invalid or missing
case class RequestErrors(errors: Seq[Throwable])
// when a required request item (header, param, cookie, body) was missing
case class NotFound(item: RequestItem)
// when type conversion failed
case class NotParsed(item: RequestItem, targetType: ClassTag[_], cause: Throwable)
// when a validation rule did not pass for a request item
case class NotValid(item: RequestItem, rule: String)
The RequestItem
is a following ADT:
sealed abstract class RequestItem(val kind: String, val nameOption:Option[String] = None) {
val description = kind + nameOption.fold("")(" '" + _ + "'")
}
case class ParamItem(name: String) extends RequestItem("param", Some(name))
case class HeaderItem(name: String) extends RequestItem("header", Some(name))
case class CookieItem(name: String) extends RequestItem("cookie", Some(name))
case object BodyItem extends RequestItem("body")
case object MultipleItems extends RequestItem("request")
As you have already seen in previous example, Finch provides the basic building blocks for request processing in the form of readers for parameters, headers, cookies and the request body.
You then perform type conversions or validations on these readers as required and combine them to build new readers:
case class Address(street: String, city: String, postCode: String)
val address: RequestReader[Address] = (
param("street") ::
param("city") ::
param("postCode").shouldNot(beLongerThan(5))
).as[Address]
These new readers can then themselves be combined with other readers:
case class User(name: String, address: Address)
val user: RequestReader[User] =
(param("name") :: address).as[User]
The example above may be rewritten with map
over the HList
and pattern-matching:
case class User(name: String, address: Address)
val user: RequestReader[User] = (
(param("name") :: address).map {
case name :: address :: HNil => User(name, address)
}
The following sections explain the difference between the applicative style (HList
style) based on the ::
combinator
you see in the examples above and the monadic style that you will only need in exceptional cases.
Almost all the examples in this documentation show the applicative syntax based on the ::
combinator for composing
readers. It is similar to scodec's ::
compositor.
case class User(name: String, age: Int)
val user: RequestReader[User] = (
param("name") ::
param("age").as[Int]
).as[User]
The ::
operator composes two request readers into a RequestReader[L <: HList]
, where the HList
type is provided by
Shapeless.
The main advantage of this style is that errors will be collected. If the name parameter is missing and the age parameter
cannot be converted to an integer, both errors will be included in the failed future, in an exception class
RequestErrors
that has an errors
property of type Seq[Throwable]
:
user(Request("age" -> "broken"))
// will return a `Future` failing with this exception:
RequestErrors(Seq(
NotPresent(ParamItem("name")),
NotParsed(ParamItem("age"), <ClassTag[Int]>, <NumberFormatException>)
))
Since the RequestReader
is a reader monad, you can alternatively combine readers in for
-comprehensions (using map
and flatMap
):
case class User(name: String, age: Int)
val user: RequestReader[User] = for {
name <- param("name")
age <- param("age").as[Int]
} yield User(name, age)
But while this syntax may look familiar and intuitive, it has the major disadvantage that it is fail-fast. If both parameters are invalid, only one error will be returned—a fact your users and client developers probably won't fancy much.
The monadic style might still be useful for the rare cases where one reader depends on the result of another reader.
Note: If you've used older versions of Finch (before 0.5.0), then the monadic style was the only way to combine readers. The applicative style has been introduced in version 0.5.0 and is the recommended combinator pattern now.
For all String
-based readers, Finch provides an as[A]
method to perform type conversions. It is available for any
RequestReader[String]
, RequestReader[Option[String]]
or RequestReader[Seq[String]]
as long as a matching implicit
DecodeRequest[A]
type-class is in scope.
This facility is designed to be intuitive, meaning that you do not have to provide a DecodeRequest[Seq[MyType]]
for
converting a sequence. A decoder for a single item will allow you to convert Option[String]
and Seq[String]
, too:
param("foo").as[Int] // RequestReader[Int]
paramOption("foo").as[Int] // RequestReader[Option[Int]]
params("foo").as[Int] // RequestReader[Seq[Int]]
The same method as[A]
is also available on any RequestReader[L <: HList]
to perform Shapeless-powered generic
conversions from HList
s to case classes with appropriately typed members.
case class Foo(i: Int, s: String)
val hlist: RequestReader[Int :: String :: HNil] =
param("i").as[Int] :: param("s") // uses Finch's DecodeRequest to convert String to Int
val user: RequestReader[User] =
hlist.as[User] // uses Shapeless' Generic.Aux to convert HList to User
Note that while both methods take different implicit params and use different techniques to perform type-conversions,
they're basically doing the same thing: transforming the underlying type A
into some type B
(that's why they have
similar names.
Finch comes with predefined decoders for Int
, Long
, Float
, Double
and Boolean
. As long as you have imported
io.finch.request._
the implicits for these decoders are in scope and can be used with the as[A]
method:
val reader: RequestReader[Int] = param("foo").as[Int]
Shapeless supplies Generic.Aux
instances for any case class, so as[A]
may also be used to convert an underlying
HList
into any case class if their arity and types are the same.
Writing a new decoder for a type not supported out of the box is very easy, too. The following example shows a decoder
for a Joda DateTime
from a Long
representing the number of milliseconds since the epoch:
implicit val dateTimeDecoder: DecodeRequest[DateTime] =
DecodeRequest(s => Try(new DateTime(s.toLong)))
The example shows the most concise way of creating a new decoder: using the factory method on the companion object of
DecodeRequest
:
def apply[A](f: String => Try[A]): DecodeRequest[A]
All you need to implement is a simple function from String
to Try[A]
.
As long as the implicit declared above is in scope, you can then use your custom decoder in the same way as any of the
built-in decoders (in this case for creating a JodaTime Interval
:
val interval: RequestReader[Interval] = (
param("start").as[DateTime] ::
param("end").as[DateTime]
).as[Interval]
A third way of using the as[A]
type conversion facility is to use one of the JSON library integrations Finch offers.
Finch comes with support for Argonaut, Jackson and JSON4S.
All these integration modules do is make the library-specific JSON decoders available for use as a DecodeRequest[A]
.
To take Argonaut as an example, you only have to import io.finch.argonaut._
to have implicit Argonaut
DecodeJSON
instances in scope:
case class Person(name: String, age: Int)
implicit def PersonDecodeJson: DecodeJson[Person] =
jdecode2L(Person.apply)("name", "age")
Finch will automatically adapt these implicits to its own DecodeRequest[Person]
type, so that you can use the as[A]
method on a reader for a body sent in JSON format:
val person: RequestReader[Person] = body.as[Person]
The integration for the other JSON libraries works in a similar way.
The should
and shouldNot
methods on RequestReader
allow the user to perform validation logic. If the specified
predicate does not hold, the reader will fail with a NotValid(item, rule)
exception. The rule
is a description that
you pass to the should
or shouldNot
methods as a string.
Note that for an optional reader, the validation will be skipped for None
results, but if the value is non-empty then
all validation must succeed for the reader to succeed.
Validation can happen inline or based on predefined validation rules, as shown in the next two sections.
For validation logic only needed in one place, the most convenient way is to declare it inline:
val adult2: RequestReader[User] = (
param("name") ::
param("age").as[Int].shouldNot("be less than 18") { _ < 18 }
).as[User]
If you perform the same validation logic in multiple readers, it is more convenient to declare them separately and reuse them wherever needed:
val bePositive = ValidationRule[Int]("be positive") { _ > 0 }
def beLessThan(value: Int) = ValidationRule[Int](s"be less than $value") { _ < value }
val child: RequestReader[User] = (
param("name") ::
param("age").as[Int].should(bePositive and beLessThan(18))
).as[User]
As you can see in the example above, predefined rules can also be logically combined with and
or or
.
Finch comes with a small set of predefined rules. For readers producing numeric results, you can use beLessThan(n: Int)
or beGreaterThan(n: Int)
, and for strings you can use beLongerThan(n: Int)
or beShorterThan(n: Int)
.
-- Read Next: Responses