There is a bunch of tickets/PRs related to the same underlying problem. It has started with two tickets:
- Issue 190 - composing routers with filters and services
- Issue 172 - mixing futures and services in the same endpoint/router
Then Jens has created a PR 184 that indicated a problem of domain types in Finch. So, I created an issue 204 to track the progress on this direction. More importantly, I've posted a first version of possible solution for the "Finch in Action" problem in PR 206. This document mostly describes an approach called "Micro Finch", that in my opinion solves the original problem.
For now, a typical Finch use-case looks like a bunch of Finagle services Service[HttpRequest, HttpResponse]
glued together with either Router
or Endpoint
. This is a totally good and reasonable approach but it doesn't really use the whole power of Finch basic blocks and more importantly, hides the actual domain types user is working with. That said, it ruins the type-safety. Good news here is that Finch already has a good potential to expose and use types that matter rather than hide them besides boilerplate HTTP types: HttpRequest
, HttpResponse
.
The idea is to allow users to write their (micro)-services working with custom domain types instead. If you want to greet a user by given name you're thinking about service String => String
rather than HttpRequest => HttpResponse
. The "Finch in Action" problem belongs to:
- Working with types that matter, i.e., getting user's groups is
User => Seq[Group]
but notHttpRequest => HttpResponse
- Not dealing with raw HTTP types directly
Good news! Finch already has everything is needed to solve the problem. On one hand, a RequestReader
hides the raw HttpRequest
type and substitutes it with domain type A
(i.e., RequestReader[A]
). On the other hand, a ResponseBuilder
hides the raw HttpResponse
type and substitutes it with domain type B
if there is an implicit view available B => HttpResponse
.
In order to approach this, we have to define an abstraction that represents a user-defined (micro)-service, working with domain types. It takes a request and returns type A
: Micro[A]
. In fact, there is already abstraction in Finch that does the same thing: RequestReader[A]
. For example, RequiredParam("a")
is a (micro)-service HttpRequest => String
.
Thus, the solution for the "Finch in Action" problem is a "Micro Finch" that, at hight level, is attempt to replace Fiangle Service
with Micro[A]
(RequestReader[A]
), which is an insanely composable version of Service[HttpRequest, A]
.
There is nothing new here rather than a type alias type Micro[A] = RequestReader
and a couple of implicit conversions that allows us to threat Router[Micro[HttpResponse]]
as a Service[HttpRequest, HttpResponse]
.
A full-featured example of usage micros is available here. Although, compositors ~>
(or map
) and ~~>
(or embedFlatMap
) should be explained in details. When dealing with composed request readers, i.e., RequestReader[A ~ B]
it's not that easy to map it to function since it requires pattern matching the type firstly:
def sum(i: Int, j: Int) = i + j
val r: RequestReader[Int] =
RequiredParam("i").as[Int] ~ RequiredParam("j").as[Int] map {
case i ~ j => sum(i, j)
}
Both ~>
and ~~>
compositors allow to threat underlying type A ~ B .. ~ Z
as (A, B, ..., Z)
. So, the example might be rewrite in a sane way:
def sum(i: Int, j: Int) = i + j
val r: RequestReader[Int] =
RequiredParam("i").as[Int] ~ RequiredParam("j").as[Int] ~> sum
Using the ~~>
we can make long calls to the underlying async API (considering that Micro
is RequiestReader
):
def fetchUserFromDb(id: Int): Future[User] = ???
def getUser: Micro[User] = RequiredParam("id").as[Int] ~~> fetchUserFromDb
That said, we can rethink the meaning of Endpoint
. Instead of two meaningless types it should carry just one type:
type Endpoint[A] = Router[Micro[A]]
So, keeping in mind that there is an implicit conversion Endpoint[A] => Service[HttpRequest, HttpResponse]
available (if there also an implicit view A => HttpResponse
around). We can write a "Hello, World!" service that returns plain/text
response to every request like this:
Httpx.serve(":8081", ** /> Micro.value("Hello, World!"))
An echo service might look as follows:
def echo(what: String): Micro[String] = Micro.value(what)
Httpx.serve(":8081", Get / "echo" /> echo)
Let's think about more complex example to see the whole picture: given interval from ... to
, return all the users from database:
def fetchUsers(from: Int, to: Int): Future[Seq[User]] = ???
case class Interval(from: Int, to: Int)
val interval: Micro[Interval] = RequiredParam("from").as[Int] ~ RequiredParam("to").as[Int] ~> Interval
val getUsers: Micro[Seq[User]] = interval ~~> {
case Interval(from, to) => fetchUsers(from, to)
}
Httpx.serve(":8081", Get / "users" /> getUsers)
This is a big step for the Finch project. I'm totally excited about where we're now. We did a fantastic job on making the RequestReader
(our cornerstone abstraction) composable as hell. The next reasonable step is to treat it not as just a reader but a complete (micro)-service.