Better Routing

Purescript Halogen

In the last post we created some simple routing for our Halogen app. But as always happens, the boss comes along and wants you to make some enhancements. We now need to add the ability to click on a particular potato to get some interesting information about that potato.

Demo

The route for the potato is going to be #potato/<potato name>. This makes just matching on strings more complicated as we need to pull the potato name out as a parameter. Luckily the routing library contains some parser combinators that make this straightforward.

Setup

We’ll start with the code from the previous post. You can download the code from here if you need.

We need to install the routing library. So in the command line cd to the project directory:

> spago install routing
Installation complete.

Routing

In src/Router.purs add the following imports:

import Control.Alt ((<|>))
import Routing (match)
import Routing.Match (Match, lit, str, end)
import Data.Foldable (oneOf)

We need to add the Potato route to our list of routes:

data Route
  = Roast
  | Chips
  | Salad
  | Potato String
  | Dud

Note that this route takes a String parameter. This represents the variety that we need to display the information for.

We have also added another route - Dud. This route will be called if we cannot match the route. It’s best to show a 404 error rather than just default to Roast.

Now we need to create a parser that will match the route. Parsing uses the Match object to match each component in the path. We build up a Match object to represent the routes we want to match with and then pass it to the match function to run the match against a route.

Let’s play with it in the repl to get an idea of how it can work.

PSCi, version 0.13.5
Type :? for help

> import Prelude
> import Routing
> import Routing.Match
> import Data.Either
>

The most basic matches is against an empty string:

> match end ""
(Right unit)

We can also match against the root path:

> match root "/"
(Right unit)

We can match against a specific string literal:

> match (lit "potato") "potato"
(Right unit)

match returns an either. If the match succeeds it returns Right. If it fails it returns Left like so:

> match (lit "potato") "turnip"
(Left "expected path part: potato;\n")

Our combinators so far don’t return any data beyond just the fact that they have matched. Other combinators do return data:

> match str "potato"
(Right "potato")

> match int "42"
(Right 42)

Match implements a number of useful typeclasses that we can use to build up our route matching routine.

Functor

Now, because Match is a Functor, we can use this to map the values returned by match. Let’s create a quick data structure to represent some routes:

> data Route = Zork | Mong | Nonk String
> derive instance eqRoute :: Eq Route
>

(We derive an eq instance, so we can test the return values. It’s a bit annoying deriving Show instances in PureScript.)

We can then use <$ which is infix for the voidRight function. This function will take a value and a Functor and will replace the value contained in the Functor with the given value.

> match (Zork <$ lit "zork") "zork" == Right Zork
true

> match (Zork <$ lit "zork") "not zork" == Right Zork
false

<$ is not just useful for parsing. It can be used anywhere you have a Functor:

> import Data.Maybe
> 3 <$ Just 9
(Just 3)

> ("I am a sausage" <$ Right 9) :: Either Int String
(Right "I am a sausage")

> ("I am a sausage" <$ Left 9) :: Either Int String
(Left 9)

> 7 <$ [1,9,2,4]
[7,7,7,7]

As a slight aside, when I said <$ replaces the value contained in the Functor, I slightly lied. Note that calling it on Left 9 didn’t change the value at all. This is because Either is only a Functor on its Right. So no value was changed. Also calling it on an array actually replaced 4 values.

We also have our favorite map aka <$> that will map the value returned by eg. str into our Route data.

> match (Nonk <$> str) "somekindanonk" == Right (Nonk "somekindanonk")
true

Alt

Match also implements Alt which allows us to specify multiple matches, choosing from the first one that succeeds:

> import Control.Alternative
> match (Zork <$ lit "zork" <|> Mong <$ lit "mong") "zork" == Right Zork
true

> match (Zork <$ lit "zork" <|> Mong <$ lit "mong") "mong" == Right Mong
true

> isLeft $ match (Zork <$ lit "zork" <|> Mong <$ lit "mong") "cactus"
true

The final example fails with Left as neither of the alternatives matched.

Apply

Many routes will have multiple segments. Match will match multiple segments and it implements Apply to allow us to match against each one.

> match (lit "ook" *> lit "onk") "ook/onk"
(Right unit)

> match (lit "ook" *> lit "onk") "ook/ugh"
(Left "expected path part: onk;\n")

The *> operator - applySecond will run whatever is on the left, ignore any result from that and then run whatever is on the right - returning the result from the right. It does this as long as we are running in the Apply context - so with an object that implements the Apply typeclass. Match implements Apply so we are good to go.

Now, run means different things depending on how the actual object has been implemented. Maybe also implements Apply:

> import Data.Maybe
> Nothing *> Just 7
Nothing

> Just 3 *> Just 9
(Just 9)

In the case of Maybe, the way it runs is to check that it has a value. In the first case we have Nothing, so the run terminates and Nothing is returned. This is similar to what would happen if we do not have a match for lit "onk".

In the second case, we do have a value (Just 3), so the run succeeds and moves on to the next case. This is Just 9 and that is what is returned. Note that the value from Just 3 is ignored, it had served its purpose.

Routing potatoes

Given that we can start building up our routes. Lets go through the various possible routes we would like to match.

First:

pure Roast <* end

This matches an empty route. If it matches it will return Roast. This gives us a way of defaulting to Roasts if no route is specified.

Roast <$ lit "roast" <* end

We match if we have a route of roast. And we map this to return Roast.

Same with chips:

Chips <$ lit "chips" <* end

And with salad:

Salad <$ lit "salad" <* end

Now for potatoes. The potato route contains an extra segment which will contain the name of the potato.

Potato <$> ( lit "potato" *> str <* end ) 

We match against the literal potato and then apply (*>) against str which will return the string value of the next path segment. This value is then mapped into Potato.

Our final route is a catch all which will match against everything not already matched:

pure Dud

We can put it together using <|>:

routing :: Match Route
routing = pure Roast <* end
          <|> Roast <$ lit "roast" <* end
          <|> Chips <$ lit "chips" <* end
          <|> Salad <$ lit "salad" <* end
          <|> Potato <$> ( lit "potato" *> str <* end ) 
          <|> pure Dud

getRoute

Finally our getRoute function needs to be adjusted to match the route using our new Match:

getRoute :: Effect (Maybe Route)
getRoute = do
  hash <- getHash
  pure $ hush $ match routing hash 

As a bonus, the routing library provides is with a getHash function that returns the hash from the browser url.

router

The router function can also be simplified as routing provides us with a matches function which will keep track of our route changes and call provided callback each time.

router :: (Route -> Effect Unit) -> Effect Unit
router cb = void $ matches routing (\_old new -> cb new)

The call back passes two parameters, the route we are moving away from (if there is one) and the new route we are moving to. We just ignore the old route and call the original callback with the new route.

matches returns an effect we can use to remove the listener if we no longer want to track the route. We don’t need this for this app, so we just ignore it with void.

Finally

There we have it. Once you get used to using these combinators it is trivial to handle some very complex routing requirements in a typesafe manner - if you get the matching code wrong so it isn’t able to match a route according to how you have set it up in the data structure, it will fail to compile.

You can find the sample code here.