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.
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.