There are architectural problems that would prevent me from using this project should I decide to write a real-world web app. It is possible this project is just fine-tuned for SPAs or that my approach is not what people use in practice. However, here are my thoughts.
Were I writing a web app, I would have seen MetaRouter as a convenient and functional way to specify the routes. I would also have had some backend that is supposed to work with these routes (I know how to work with Java Servlets, Finch, Scalatra and Play, so I'd probably chose one of these. Not the first one for sure :) ).
The lifecycle of a route you present a user with goes as follows:
- Create a route
- Create a mapping of a route to a case class
- Create a parse table with all your routes
- Invoke
parse
method on that table to generate case classes
Examples in the context of other frameworks
Consider we want to describe a path /details/{int}
.
Java Servlets
Everything is fine there. You can define a handler for any request that comes your way, extract the request URL, parse it via the parse table and match on the case class. Another thing is that no Scala programmers really use that.
Finch
The endpoints are specified individually for each URL, you can't specify one endpoint for all. For /details/{int}
you will write "details" :: int
. And you do not want to parse that to a case class - I would have found it a clatter on Finch. What we want to do instead is handle the request in place. We had experience with Finch and MetaRouter, so we know how it went. Precisely, we ended up fold
ing the HList
with path fragments.
Scalatra
Endpoints are specified in some controller, and for our example you would write get("/details/:int") {/* handling code */}
. In in Finch
we were fold
ing the path to its own format, here we need to fold
it to a String, with the framework-specific formatting. Again, I do not see any need for case classes here, I want to handle the request in place.
Play
Even more interesting here, it requires you to have a separate file with all your routes mapped to the controllers. Very heavy approach, if you ask me. Were I using MetaRouter here, I would expect to take the entire List
of routes I have and fold it into that file, on compile time. Naturally I do not want the mapping to case classes here either - what I want instead is the controllers with the handling code to be generated for me.
Conclusion
The approach MetaRouter presets to users has an implication that the web framework the users are using has a handler method that will handle all the requests, then the control is given up to MetaRouter.
However, this is a scenario valid for the low-level frameworks like Servlets - most people will use something higher-level, and they may have their own ways of specifying handlers.
Also, mapping requests to case classes is heavy, I personally would not want to define extra classes just for the requests, as it clutters the code.
Architectural improvements
Let's see how the lifecycle above could be adapted to make a seamless integration with these frameworks possible:
1. Create a route
The routes in their current form are good: all the elements are reified into a HList
, so that they can be easily accessible.
2. Create a mapping of a route to a case class
More often than not we want another operation to be performed instead - not a mapping to a case class, but a handling function execution. Case class is just a special case of handling an incoming request - (id: Int) :: HNil => Details(id)
. So why have Router.route
generate a MappedRoute
infrastructure, if we can have an arbitrary function there, resulting in a HandledRoute
?
3. Create a parse table with all your routes
This is good, because all the routes end up in the same data structure and are easily accessible from the outside world.
4. Invoke parse
method on that table to generate case classes
parse
method will hardly ever be invoked in high-level frameworks, since the framework takes care of parsing the URL. Instead, we want to collapse this parse table into a set of instructions for the framework on how to handle the requests.
Theory
What I am talking about here is a more "free" approach to things:
- Define your routes in a structure, describing them perfectly (as in
HList
s and case classes, where all the members and types are visible), but not enforcing you to do things in a particular way (as in mapping stuff to case classes and then parsing URLs via the parse table).
- Define capabilities to collapse these data structures to something concrete, like
fold
in the current implementation - catamorphisms - see this paper. This "something concrete" is a set of instructions for the target framework on how to do things.
Practice
Let's see how to do best our /details/{int}
example, so that it is usable from the frameworks described above.
Step 1 from Theory:
The route becomes Route(GET, "details" :: Arg[Int] :: HNil)
, as it is now.
Moreover, we also want to encapsulate the handling code here - instead of Router.route[CaseClass](ourRoute)
, we want the actual handling logic encapsulated here:
val route = MappedRoute(Route(GET, "details" :: Arg[Int] :: HNil), (id: Int) :: HNil => FlatMapped(GetModelFromDB(id), model => ReturnTemplate(model))
val table = RouteTable(route :: HNil)
(Free monad goes after =>
, probably also doable with an effectful monad. No problem if we don't need the handlers here and actually need just a case class as in the current implementation - just use Route
, see Java Servlet example below).
Note how we have managed to describe both the route and the handling logic without depending on any framework. As well as to unify all our routes (one) into a table, which is a wrapper over HList
.
Naturally in the real world people usually stick to a single framework (maybe). But for me, this framework independency is for reducing the mental load of learning a new framework foremost: you first define what you want to do, and then take your time to learn how to do it with the framework in question.
Step 2 from Theory
Now let's see how the above works out with the four frameworks from the previous section. For every framework, we need to collapse table
(apply a catamorphism) to something the framework will understand.
Java Servlets
The current implementation works fine, let's see how to mimic it.
trait Mapping
case class Details(id: Int) extends Mapping
How the catamorphism will map things:
Route
--> String => Option[Mapping]
- a route optionally can match a string, and if the match succeeds, the case class is returned (more type safety needed here, Mapping
should be Details
)
MappedRoute(route, handler)
--> (String => Option[Mapping], Mapping => Unit)
- if you have already associated a handler with the mapping.
RouteTable(mappedRoutes)
--> String => Unit
- optionally handle the mapped routes
RouteTable(nonMappedRoutes)
--> String => Option[Mapping]
- if you have not specified the handlers, but need the case classes instead.
Finch
Route(GET, "details" :: Arg[Int])
--> get("details" :: int): Endpoint
MappedRoute(route, handler)
--> collapseRoute(route).apply(handler): Endpoint
RouteTable(routes <: HList)
--> routes.map(collapseRoute) match { case r1 :: r2 :: ... :: HNil => r1 :+: r2 :+: ... }: Endpoint
Scalatra
Route(GET, "details" :: Arg[Int])
=> "details/:int": String
MappedRoute(route, handler)
=> get(collapseRoute(route)) { handler }
(get
apparently is effectful in Scalatra; and translate that GET
into get()
somehow, I did not think that far, but should not be hard)
RouteTable(routes)
=> routes.foreach(collapse)
- effectfulness is evil, but that's what the framework wants.
Play
Route(GET, "details" :: Arg[Int])
=> "GET /details/:int Handlers.firstHandler"
- the entry of the routes file.
MappedRoute(route, handler)
=> inject the def firstHandler = {handler}
method in the synthetic Handlers
class - probably from a macro. Play is heavy.
RouteTable(routes)
=> routes.map(collapseRoute).mkString("\n")
- and then this string becomes the routes
file.