Router

This package contains a SPA (Single Page Application) router, based on Servant combinators.

The routing schema is a surjection between URLs and routes, in that more than one URL could result in the same route.

data Route = Home | About Search

We may have a URL mapping as follows:

Evolve Routes
You can take advantage of this property to support legacy URLs and evolve a routing schema over time.

This injective mapping in the opposite direction we will call toRoute : URL → Route. To programatically navigate the application, we need a mapping from routes to URLs. This mapping documents the canonical URL for a given route.

Let’s call this mapping fromRoute : Route → URL

These functions abstractly would have the following relationship:

∀route. toRoute (fromRoute route) = route
∃!url. fromRoute (toRoute url) = url
Dependant Types
These functions are not directly representable in Haskell so long as we intend on using Servant to describe our URLs, because URL descriptors are types and routes are terms.

Example Types

Using the above example, below you describe your types. Note again that URL descriptors are types, yet routes are terms.

type Search = Text
data Route = Home | About Search
type SPA = "v2" :> "about" :> QueryParam "search" Search :> Raw
      :<|> "about" :> QueryParam "search" Search :> Raw
      :<|> "home" :> Raw
      :<|> Raw
Order and Specificity
You must consider the order of the URLs separated by :<|>. Servant works like a pattern match, going from top to bottom, however because the match is type level, unreachable routes are entirely possible (GHC does not support checking for unreachable type level patterns), so think carefully about order and specificity.

To Route

To map URLs to routes, use a servant combinator exposed by this package :>>:

routes :: SPA :>> Route
routes = About (1)
    :<|> About
    :<|> Home
    :<|> Home
1 Because you used QueryParam "search" Search in the first route, at this location in the mapping you must provide a function of type Maybe Search → Route.
Order is important
The burden is on the developer to put the right constructor at each location in the Servant specification, which while typically safe, is not fool-proof.

From Route

The mapping of routes to URLs is a bit inelegant at the moment. It should be possible to eliminate in future releases with the use of GHC.Generics:

instance Routed SPA Route where
  redirect = \case
    Home         -> Redirect (Proxy @("home" :> Raw)) id
    About search -> Redirect (Proxy @("v2" :> "about" :> QueryParam "search" Search :> Raw)) ($ search)

The above mapping is type safe, and URLs not present in the SPA type will not compile. Any terms that can be captured in the pattern matching must be used for this instance to be lawful.

Running the Router

To use the router, it is expected that you use a function to start your application (such as fullPageSPA), where there is a (r → m a) argument and where you can describe how to obtain an application state based on a given route. This is useful both for pure frontend applications, as well as applications that use isomorphic rendering with the static renderer.

This is typically a relationship between Routes and a sum type of page data. You can use the m in (r → m a) to perform IO and populate our model with data:

data Model
  = HomePage
  | AboutPage AboutPage

data AboutPage = AboutPage Search [BlogPost]

initial :: MonadJSM m => Route -> m Model
initial = \case
  Home -> return HomePage
  About search -> do
    posts <- getBlogPosts
    AboutPage search $ searchFilter search posts

For an example that takes advantage of server-side rendering and isomorphic Servant route sharing, see the Servant CRUD Example.