A Guide to purescript-routing
September 27, 2021 · View on GitHub
purescript-routing consists of two core features
- An
Applicativeparsing framework for paths (Routing.Match) - Routing effects and events (
Routing.HashorRouting.PushState)
Parsing routes with Routing.Match
In many routing frameworks, you might start by using a stringly-typed DSL for paths:
const router = new Router();
router.on('/posts', () => {
// Handle index
});
router.on('/posts/:postId', (postId) => {
// Handle post
});
router.on('/posts/:postId/edit', (postId) => {
// Handle edit
});
route.on('/posts/browse/:year/:month', (year, month) => {
// Handle browsing
});
In this example interface, syntax of the form :slug indicates that the
value should be extracted from the path and provided to the callback handler.
With purescript-routing, we start by defining a data type for our routes.
type PostId = Int
data MyRoute
= PostIndex
| Post PostId
| PostEdit PostId
| PostBrowse Int String
By using a data type, we can use case analysis to guarantee that we've
handled all possible routes both when dispatching and when constructing URLs.
If you can only construct a URL from your route, then it's impossible to
construct an invalid URL.
To turn a stringy path into our data type, we need to define a parser using
combinators in Routing.Match as well as standard Applicative and
Alternative combinators.
import Prelude
import Control.Alternative ((<|>))
import Routing.Match (Match, lit, int, str, end)
The available Match combinators are:
lit– Matches literal path segments. For example,lit "posts"would match the path segment "posts" in our example URL.num– Matches and returns aNumbervalue.int– Matches and returns anIntvalue.bool– Matches and returnstrueorfalse.str– Returns the path segment as is.param– Extracts and returns a query parameter given a key.params– Returns all query parameters.end– Matches the end of the path.
Lets define a route for PostIndex. This route has no parameters, so all we
need to do is match the literal path segment "posts".
postIndex :: Match Unit
postIndex = lit "posts"
However, this just yields a Unit value, and we need MyRoute. If there are
no interesting values we want to consume, we can use <$ from Prelude.
postIndex :: Match MyRoute
postIndex =
PostIndex <$ lit "posts"
Our next routes require extracting an integer PostId.
post :: Match MyRoute
post =
Post <$> (lit "posts" *> int)
postEdit :: Match MyRoute
postEdit =
PostEdit <$> (lit "posts" *> int) <* lit "edit"
Note the use of the *> and <* operators. These let us direct the focus of
the value we want to consume. In postEdit, we want to consume the int,
but we also need to match the "edit" suffix. The arrows point to the value we
want.
Note that in general parentheses are required when using *> since the
operator precedence is not what is required (resulting in type errors
otherwise.)
And now finally, we need to extract multiple segments for PostBrowse.
postBrowse :: Match MyRoute
postBrowse =
PostBrowse <$> (lit "posts" *> lit "browse" *> int) <*> str
The <*> combinator has arrows on both sides because we want both values.
This works for any number of arguments our route needs. Just keep using
<*>.
Now to pull these all together, we can use <|> from Control.Alternative.
The routes will be tried in order until one matches.
myRoute :: Match MyRoute
myRoute =
postIndex <|> post <|> postEdit <|> postBrowse
Additionally, we can use oneOf from Data.Foldable which folds a data
structure using <|>.
import Data.Foldable (oneOf)
myRoute :: Match MyRoute
myRoute = oneOf
[ postIndex
, post
, postEdit
, postBrowse
]
We can also go ahead and inline our parsers.
myRoute :: Match MyRoute
myRoute = oneOf
[ PostIndex <$ lit "posts"
, Post <$> (lit "posts" *> int)
, PostEdit <$> (lit "posts" *> int) <* lit "edit"
, PostBrowse <$> (lit "posts" *> lit "browse" *> int) <*> str
]
You'll see we have some duplication. We are repeating the "posts" literal. One of the great things about PureScript combinators is we can intuitively factor things like this out and we know it will keep working. Since they all start with "posts", we can just match that first.
myRoute :: Match MyRoute
myRoute =
lit "posts" *> oneOf
[ pure PostIndex
, Post <$> int
, PostEdit <$> int <* lit "edit"
, PostBrowse <$> (lit "browse" *> int) <*> str
]
This is a lot clearer, but we may have found a bug! Our first route is pure PostIndex. There are no other conditions to match so this route will always
succeed, and our subsequent routes won't match. One thing we could do is
rearrange our routes so that PostIndex is last, but that just means
PostIndex will match anything under "posts". What we really want to do is
match "posts" exactly with no extra path segments. For that we should use the
end combinator.
myRoute :: Match MyRoute
myRoute =
lit "posts" *> oneOf
[ PostIndex <$ end
, Post <$> int <* end
, PostEdit <$> int <* lit "edit" <* end
, PostBrowse <$> (lit "browse" *> int) <*> str <* end
]
It seems like we might be able to factor out the end like we did with lit "posts", but that will bring us right back to our bug. It would match any of
the routes followed by an end, so we would still have to rearrange them.
myRoute :: Match MyRoute
myRoute =
lit "posts" *> oneOf
[ PostEdit <$> int <* lit "edit"
, Post <$> int
, PostBrowse <$> (lit "browse" *> int) <*> str
, pure PostIndex
] <* end
We've reduced duplication, but this might be more brittle under refactorings since the ordering is very specific.
One last detail is the leading slash. purescript-routing doesn't require a
leading slash since URL hashes might not contain them, but we can match this
with the root combinator.
myRoute :: Match MyRoute
myRoute =
root *> lit "posts" *> oneOf
[ PostEdit <$> int <* lit "edit"
, Post <$> int
, PostBrowse <$> (lit "browse" *> int) <*> str
, pure PostIndex
] <* end
We can now test out our parser using match.
import Routing (match)
import MyRoute (myRoute)
matchMyRoute :: String -> Either String MyRoute
matchMyRoute = match myRoute
test1 = matchMyRoute "/posts"
test2 = matchMyRoute "/posts/12"
test3 = matchMyRoute "/posts/12/edit"
test4 = matchMyRoute "/posts/browse/2004/June"
test5 = matchMyRoute "/psots/bad"
Routing events with Routing.Hash
Now that we have a parser, we'll want to respond to events and fire a
callback like in our original example. purescript-routing supports
hash-based routing via Routing.Hash. Hash-based routing uses anchors
(# or "hash" character) to specify the routes.
For example: www.example.com/#posts/12/edit.
import Routing.Hash (matches)
import MyRoute (myRoute)
The matches combinator takes a Match parser and an Effect callback,
providing the previously matched route (wrapped in Maybe since it may be
the initial route) and the currently matched route. You might use this
callback to push an input to an instance of a running application.
main = do
matches myRoute \_ newRoute -> case newRoute of
PostIndex -> ...
Post postId -> ...
PostEdit postId -> ...
PostBrowse year month -> ...
Note that matches will ignore routes that don't parse successfully. To
explicitly handle "not found" routes, we can add a fallback route.
maybeMyRoute :: Match (Maybe MyRoute)
maybeMyRoute = oneOf
[ Just <$> myRoute
, pure Nothing
]
main = do
matches maybeMyRoute \_ newRoute -> case newRoute of
Nothing -> ... -- Not found
Just PostIndex -> ...
Just (Post postId) -> ...
Just (PostEdit postId) -> ...
Just (PostBrowse year month) -> ...
Alternatively, we could explicitly add a NotFound constructor to MyRoute.
Routing events with Routing.PushState
PushState-based routing avoids the use of anchors (#) to specify the routes.
For example: www.example.com/posts/12/edit.
Routing with Routing.PushState is similar to hash-based routing except that
we must first create an interface. Browsers don't handle location events
directly, so the interface needs to do some bookkeeping of it's own for
handling subscriptions.
import Routing.PushState (makeInterface, matches)
import MyRoute (myRoute)
main = do
nav <- makeInterface
nav # matches myRoute \_ newRoute -> case newRoute of
PostIndex -> ...
Post postId -> ...
PostEdit postId -> ...
PostBrowse year month -> ...
Use the created interface to push new states and routes. States are always
Foreign because they are global and may come from anywhere. We cannot
provide a well-typed interface with any guarantees.
import Foreign (unsafeToForeign)
main = do
nav <- makeInterface
...
nav.pushState (unsafeToForeign {}) "/about"
One option is to use purescript-simple-json which provides easy codecs to
and from Foreign for JSON-like data.
import Simple.JSON (read, write)
type MyState =
{ foo :: String
, bar :: Int
}
main = do
nav <- makeInterface
_ <- nav.listen listener
nav.pushState (write { foo: "foo", bar: 42 }) "/about"
where
listener location = case read location.state of
Right { foo, bar } -> ...
Left errors -> ...
Using newtype in routes
Using the following routes with a newtype:
newtype PostId = PostId Int
derive instance newtypePostId :: Newtype PostId _
data MyRoute
= PostIndex
| Post PostId
| PostEdit PostId
| PostBrowse String String
It is possible to wrap an int route parameter into a PostId using the
following function:
postId :: forall f. MatchClass f => f PostId
postId = PostId <*> int
The created postId function can then be used like the parser function.
myRoute :: Match MyRoute
myRoute =
root *> lit "posts" *> oneOf
[ PostEdit <$> postId <* lit "edit"
, Post <$> postId
, PostBrowse <$> (lit "browse" *> int) <*> str
, pure PostIndex
] <* end