The obelisk-route
package is designed to help with managing the paths and parameters for routing
in your application.
Most importantly it has been designed and built to provide the following guarantees:
- Every route value can be encoded to a URL
Every route that you declare in your types will produce a valid URL; that is, encoding cannot fail.
- When you decode something you've encoded, you'll always get the same value back
decode . encode = pure
Routes declared using obelisk-route
are bidirectional, meaning that any route that can be encoded
to types and matched on can also be encoded as a url without losing information.
- Common modifications made to routes, where ever possible, will be caught by the compiler.
When you make common modifications to routes, or a route is added or removed, there will be "incomplete case" and similar warnings wherever you need to update your application. The type system also makes it much easier to catch those easy-to-forget issues, such as updating nav-bars, or that breadcrumb widget that needs a human-readable description.
- Behaviour of routes is identical for Backend and Frontend
This package doesn't have a distinction between backend or frontend routes, these are only different types so their behaviour is identical. This guarantees that routes generated as part of a backend static rendering process will be indistinguishable from routes generated on the frontend.
It may seem odd that obelisk-route
does not provide a way to specify the HTTP Method for a given
route. This is due to the requirement that this package may be used when rendering a page on the
backend or the frontend. A POST request makes no sense for the frontend of an application so we
do not support creating this type of route. As it would be difficult to impossible to enforce if
the routing system worked differently on the backend vs the frontend.
It's worth quickly pointing out the parts of the URL that this package is focused on so that when we're discussing terms like 'path' and 'query', we're thinking of the same thing.
From the Wikipedia page on URIs:
The URI generic syntax consists of a hierarchical sequence of five components:
URI = scheme:[//authority]path[?query][#fragment]
where the authority
component divides into three subcomponents:
authority = [userinfo@]host[:port]
This package handles the path
and query
components of a URI.
The way to approach building routes with obelisk-route
is to focus on your Route data structure.
First, make sure it matches your application's structure: with one Route value for each logical
page the user might want to visit. Only once that is clear, move on to thinking about how those
logical pages ought to appear in the user's address bar. For instance, you might want each user's
profile to show up at /user/$userid
.
Finally, consider how you want to handle URLs with slight differences; an extra trailing slash, different capitalization, or URLs whose rendering format has changed. By doing things in this order, you design your routes around your application, rather than designing your application around your routes.
We then write the definition of the route using Encoder
s. These may be thought of as small pure
functions that compose together using (.)
to build the concrete definition of your routing
structure. By composing and building routes from pieces that are so small that they are "obviously
correct", you can be confident that the composition of those individual pieces is also correct.
As we build up some examples in this guide, we will demonstrate how to think about your abstract
routes as concrete definitions using Encoder
s. You will see that obelisk-route
already has most
of the tools you need to construct that concrete definition. We'll also cover what to do when it
doesn't, and what you need to know to ensure that you build correct and composable pieces.
For a hands-on experience, work through the section you're interested in using a freshly created Obelisk application. Refer to the Obelisk documentation for Developing an Obelisk project.
This route will be the main landing page of our application. There is only one possible route associated with this page, so the value for our route must have only one possible instantiation.
When it comes to how that route will present itself to the user in the address bar, it will be our
main page and at the root of all things so it must be /
.
The routes for a Obelisk application often live in Common.Route
and nearby modules. We will follow
this convention and create our type there:
data MyRoute = MyRoute_Main
deriving (Show, Eq, Ord, Enum, Bounded)
The constructor for our main page is MyRoute_Main
with no parameters. This value will be used in
our case
expressions to decide what code to run and when we're creating links to this page. Be
sure to include the deriving clause as we will need these instances.
The naming of the route MyRoute_Main is an Obelisk convention of including the type name in individual constructor names. This helps disambiguate the code at the 'cost' of a few extra keystrokes. As an example:
data Foo = Foo_ConstructorA | Foo_ConstructorB
We have the logical definition of the route as the MyRoute_Main
constructor, and the next step is
to create the concrete definition of that route for our application. We will do this by building our
first Encoder
. Below the MyRoute
type, create a function with the following type signature:
myRouteEncoder
:: ( Applicative check
, MonadError Text parse
)
=> Encoder check parse MyRoute PageName
myRouteEncoder =
undefined
You will need the constraints to satisfy the typechecker for the type variables; check
, and
parse
. They're not important to what we're doing now.
This is the primary building block for any route definition:
data Encoder check parse a b
An Encoder check parse a b
may be thought of as small pure functions of type: a -> b
. Thinking
of them like this may provide some intuition as Encoder
s may be composed using (.)
, and in some
ways treated as if they were nothing but pure functions. In practice they are more complex than
this, but it is a good place to start.
The check
and parse
types are related to the functions that the Encoder
uses to encode or
validate route input. When using existing Encoder
s these type variables are often able to be
ignored. They are more relevant when you're building your own Encoder
s.
They are also bidirectional so the one Encoder
will handle going from a
to b
and
back. Importantly, a correctly built Encoder
will not lose information, regardless of which
direction the information is travelling:
forall a. decode (encode a) == pure a
Within the Obelisk.Route
module are many pre-built Encoder
s, the one we will use is the enumEncoder
:
enumEncoder
:: forall parse check p r.
( Universe p
, Show p
, Ord p
, Ord r
, MonadError Text parse
, MonadError Text check
, Show r
)
=> (p -> r)
-> Encoder check parse p r
Update myRouteEncoder
to use this Encoder
and place a 'typed hole' as the argument:
myRouteEncoder
:: ( Applicative check
, MonadError Text parse
, MonadError Text check
)
=> Encoder check parse MyRoute PageName
myRouteEncoder = enumEncoder _todo
That _todo
is called a 'typed hole' and they allow you ask GHC "what should be the type of the
thing at _todo
". They are very useful for debugging and incremental development. They may be used
anywhere you can use a function or a value. For more information on typed holes, see the following links:
They are a very useful tool for building & debugging your program. But do not succumb to 'type tetris'. This is when you find yourself replacing typed holes with the first thing that satisfies the type checker, but doesn't necessarily result in a useful or correct program.
Typed holes don't have to be called _todo
, you can give them any valid Haskell identifier name, as
long as they start with _
and don't clash with anything other names.
NB: Typed holes still rely on GHC type checking and inspecting your codebase. So if you've a large codebase then resolving the typed hole may be slow.
If you're following along with either ob run
, ghcid
, or
building as you go, the output will now contain two errors relating to our use of enumEncoder
.
The first is related to the Universe
constraint from the universe
package:
• Could not deduce (Universe MyRoute)
arising from a use of ‘enumEncoder’
from the context: (Applicative check, MonadError Text parse)
bound by the type signature for:
...
The enumEncoder
uses the Universe
typeclass to ensure coverage for every possible value for type
p
. We need an instance of this typeclass for our MyRoute
type in order to use enumEncoder
, we
don't have to do much as there is a default implementation for any type that has instances for
Enum
and Bounded
, which we have already derived.
You may need to import the Data.Universe
module if you haven't already.
Then declare an instance for our type like so:
instance Universe MyRoute
Save the file and the next error is feedback from the 'typed hole':
• Found hole: _todo :: MyRoute -> PageName
Or perhaps ‘_todo’ is mis-spelled, or not in scope
• In the first argument of ‘enumEncoder’, namely ‘_todo’
In the expression: enumEncoder _todo
This is the specialised type for the p -> r
function for enumEncoder
. We have to write the
function that describes how to create the PageName
for each value of our sum type. We might only have a
single constructor at the moment but replace the _todo
typed hole with a case
expression to
match on the MyRoute
input, leaving a typed hole on the right hand side of the ->
:
myRouteEncoder
:: ( Applicative check
, MonadError Text parse
, MonadError Text check
)
=> Encoder check parse MyRoute PageName
myRouteEncoder = enumEncoder $ \myRoute -> case myRoute of
MyRoute_Main -> _todo
A slightly nicer way of writing a case
expression when you're matching on a single input to a
lambda is to turn on the LambdaCase
language
extension. This will work exactly the same
as a normal case expression, but it is a bit tidier:
myRouteEncoder
:: ( Applicative check
, MonadError Text parse
, MonadError Text check
)
=> Encoder check parse MyRoute PageName
myRouteEncoder = enumEncoder $ \case
MyRoute_Main -> _todo
This new typed hole will inform us that the return type of this case
expression needs to be
something of type PageName
.
The PageName
type is an alias for a tuple of URL and any query parameters:
- The URL as a list of the individual route segments:
[Text]
. - Query parameters represented using
Map Text (Maybe Text)
.
It is the target type of a complete route Encoder
.
The URL of our main pain is /
which is represented by an empty list and because we don't have any
query parameters, that will be an empty map.
Fill in the typed hole with an empty PageName
tuple:
myRouteEncoder
:: ( Applicative check
, MonadError Text parse
, MonadError Text check
)
=> Encoder check parse MyRoute PageName
myRouteEncoder = enumEncoder $ \case
MyRoute_Main -> ([], mempty)
NB: Map
has an instance of Monoid
, so we can use mempty
to create an empty Map
for us.
We now have an Encoder
that represents the concrete definition of our route structure. The single
page with only one possible constructor MyRoute_Main
and it's representation in the address bar:
([], mempty)
.
Next we're going to add a privacy policy page to our list of routes. This route will be similar to
our main route in that there will be only one possible instantiation of this route. The
representation in the address bar will be different, in this case the expected route will be
/privacy
.
To represent this we will extend the MyRoute
type with another constructor:
data MyRoute
= MyRoute_Main
| MyRoute_PrivacyPolicy
deriving (Show, Eq, Ord, Enum, Bounded)
Because we have added another route and we're using a case
expression in the enumEncoder
, the
compiler will start displaying warnings about 'non-exhaustive' pattern matches. These may even be
regarded as errors depending on your compiler options. The compiler output will look a bit like this:
common/src/Common/Route.hs:(77,36)-(79,42): warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In a case alternative: Patterns not matched: MyRoute_PrivacyPolicy
|
77 | myRouteEncoder = enumEncoder $ \case
| ^^^^^...
With the logical definition of our privacy policy route created, we will alter our Encoder
to
account for the new constructor. We will add to the case
expression to match on the different
constructors and let us the define the correct PageName
for the new page:
myRouteEncoder = enumEncoder $ \case
MyRoute_Main -> ([], mempty)
MyRoute_PrivacyPolicy -> _privacyTodo
The path for the MyRoute_PrivacyPolicy
page is /privacy
. To construct the corresponding
PageName
value we use a single element list as the path has only a single segment: "privacy"
.
There are still no query parameters so that part of the PageName
is still an empty Map
:
myRouteEncoder = enumEncoder $ \case
MyRoute_Main -> ([], mempty)
MyRoute_PrivacyPolicy -> (["privacy"], mempty)
In many routing systems you match directly on the route input and break it down as required. However this often means that you are left on your own when comes to creating links for these routes because there is no way to relate the structure of a route to anything. If any of those routes change it can be a tedious and error-prone process to find and fix all the constructed links.
Obelisk routes are bidirectional, which means the Encoder
that you create also works as a 'pattern
match' for incoming routes. The route types operate as a type safe mechanism for creating links in
your application. It is a compile error to try to use route constructors that don't exist, and if
you change the type of a route the application will not build until you fix that change every where
it appears.
Applications often have a hierarchy to keep things organised. A request is identified as belonging to a particular sub-category and handed off accordingly, the sub-category is then responsible for handling the finer details. The end result being that the whole system may be quite complex but the individual components are easier to create, maintain, and more obviously correct due to their constrained scope.
This section will have a top level Encoder
with two sets of nested routes; one for a backend API,
and another for the frontend of our application. The nested routes will be kept simple as the focus
is on how to nest them. This hypothetical system is comprised of two subsystems, with the following
functionality:
The backend offers three different functions:
- check the status of the system
- request the current version of the system
- request the current uptime duration
The frontend has three pages:
- user registration page
- 'contact us' page
- 'about us' page
Lets think about what the abstract definition of these routes might look like. We have six unique bits of functionality that we want to account for, so we could have a single structure that has everything in one big list. But that doesn't reflect that there are two logically separate systems, and the routes must conform to our needs, not the other way around.
To anyone using our system however there is no such separation, so we need a way to decide how to delegate properly. For that we need another top level structure that is able to decide if something is a backend or frontend request. This top level shouldn't do anything but decide which system to forward the request to, but in doing so it would present the appearance of this 'single' system and allow us to maintain the logical distinction and keep things organised.
So we know that at a high level we need the following abstract route structures:
ApiRoute
for the backendAppRoute
for the frontendTopLevel
to delegate
Based on the earlier descriptions of the different backend and frontend functionality we can divide them up into the following data structures:
data ApiRoute
= ApiRoute_Status
| ApiRoute_Version
| ApiRoute_Uptime
deriving (Show, Eq, Ord, Enum, Bounded)
instance Universe ApiRoute
data AppRoute
= AppRoute_Register
| AppRoute_Contact
| AppRoute_About
deriving (Show, Eq, Ord, Enum, Bounded)
instance Universe AppRoute
As for the representation of these routes as paths, the backend api routes are:
/status
/version
/uptime
The frontend routes are:
/register
/contact
/about
We'll define the Encoder
s for these later using enumEncoder
s as we have done in earlier
sections. The focus here is on the TopLevel
structure to perform the delegation and maintain
the organisation we require.
We want a structure that will allow us to organise the two sub-systems into a hierarchy, keeping their respective paths isolated from one another and ensure that neither sub-system, nor this top level, have or need any special awareness of each other.
To that end we will create a type that will allow us to 'chain' together the different structures
while enabling us to handle them separately. In earlier sections the route had a single possible
instantiation, but a route for the TopLevel
could be any one of the sub-routes.
To achieve the desired modularity we use the type of the sub-route as an argument to this top-level route constructor.
We will build this route in Common.Route
using a Generalised Algebraic Data Type, or 'GADT'. Part of the reason for this is that it provides more detailed type information for greater type-safety that aid in providing the guarantees that this package is built upon.
If you've not encountered GADTs before, or you're a bit rusty, check out the following links for more information:
It's not necessary to have a deep understanding of GADTs to use them with obelisk-route
, we'll
provide enough of information to be able to get by.
In the Common.Route
module add the following line:
data TopLevel :: * -> * where
This is the declaration of our TopLevel
type, the * -> *
annotation indicates that this type
requires an additional type argument. This is the part of the abstract route definition that will be
determined once we know which constructor will be used:
data TopLevel :: * -> * where
TopLevel_API :: TopLevel ApiRoute
TopLevel_APP :: TopLevel AppRoute
-- Template Haskell to generate required instances.
deriveRouteComponent ''TopLevel
These are our constructors for the routes. As in earlier sections, these following the naming
convention of including the type name. On the right hand side of the ::
we provide explicit type
for each constructor.
Now that we have the logical structure of our route defined, we will start writing the Encoder
to
build our concrete definition. Each constructor now represents either all possible routes to either
ApiRoute
or AppRoute
with the logical distinction between the two.
The following paths will be defined in our Encoder
and as with our constructors the Encoder
for
the TopLevel
is only aware of and responsible for enough of the structure to handle deciding which
sub-structure provide the Encoder
for. This Encoder
has zero knowledge of any of the sub-routes
and only handles the minimum to perform its task:
/api/...
~ backend/app/...
~ frontend
With those paths in mind we can start constructing our Encoder
:
topLevelRouteEncoder
:: ( MonadError Text check
, MonadError Text parse
)
=> Encoder check parse (R TopLevel) PageName
topLevelRouteEncoder = _todo
This similar to earlier Encoder
s with the main difference being the use of the R
type to wrap
our TopLevel
type.
When referring to route types in type signatures when they are defined as 'GADT's, we need to ensure
that the route type remains polymorphic. Using the route type TopLevel a
as an example, each
constructor may specify a different type in place of that a
. Consequently we can't expose that a
in the type signature because the type system will demand that a
have a single concrete type.
To keep the polymorphism that we need, we wrap the route type using DSum
from the 'dependent-sum' package. This uses existential quantification to prevent the type variable from our route type appearing in the type signature, preserving polymorphism without any loss of type safety.
Using the full DSum TopLevel Identity
type everytime we wanted to refer to our route type would be
tedious and noisy so we use the type alias R
as shorthand:
type R f = DSum f Identity
The Encoder
we will use is pathComponentEncoder
, which works almost exactly
like enumEncoder
with the ability to leverage the extra type information carried by our GADT.
pathComponentEncoder
:: forall check parse p.
( Universe (Some p)
, GShow p
, GCompare p
, MonadError Text check
, MonadError Text parse )
=> (forall a. p a -> SegmentResult check parse a)
-> Encoder check parse (R p) PageName
Where the type variable p
is replaced by TopLevel
and the Template Haskell we added after our
TopLevel
definition will take care of the constraints for this function. Of interest to us is the
function we need to write to make this Encoder
work:
=> (forall a. p a -> SegmentResult check parse a)
This is similar to the p -> r
function that is required by the enumEncoder
to handle the
different cases of the sum type. Now we have more type information available to us, and instead of
directly constructing the PageName
ourselves, we build values of type SegmentResult
and
pathComponentEncoder
handles the rest for us.
This type is part of the interface to the pathComponentEncoder
function. Because routes may be
nested in the definition of other routes. All of the routes related to pull requests nested under
the route for a repository, for example. We need a way to describe whether a route may have more
segments or if we've reached the end.
To do this, the SegmentResult
has two constructors, one recursive allowing for more structure, and
the other terminating, indicating that this path is complete.
The recursive constructor has the following type:
PathSegment :: Text -> Encoder check parse a PageName -> SegmentResult check parse a
This lets us specify a fixed component of a path, and then we must also provide an Encoder
to turn
this value (the one on the left hand side of the case expression) into the remainder of the route.
The other constructor:
PathEnd :: Encoder check parse a (Map Text (Maybe Text)) -> SegmentResult check parse a
Indicates that the path is complete and requires an Encoder
to turn this value into query parameters.
Refer to the Haddock documentation for more detailed information.
Create the function from a case
expression and place a typed hole on the right hand side of each branch:
topLevelRouteEncoder
:: ( MonadError Text check
, MonadError Text parse
)
=> Encoder check parse (R TopLevel) PageName
topLevelRouteEncoder = pathComponentEncoder $ \case
TopLevel_API -> _apiTodo
TopLevel_APP -> _appTodo
The type of pathComponentEncoder
indicates we need to return a type SegmentResult
. Because both
of these routes have more segments defined in a separate Encoder
and we have a intermediate static
segment to differentiate the two routes, we will use the PathSegment
constructor:
PathSegment Text (Encoder check parse a PageName)
Add this constructor to the right hand side of the case
branches each route. Use "api" as the
first argument for TopLevel_API
, and "app" for TopLevel_APP
. Leave a typed hole as the
second argument for both:
TopLevel_API -> PathSegment "api" _apiTodo
TopLevel_APP -> PathSegment "app" _appTodo
The type of the _apiTodo
hole will be:
• Found hole:
_apiTodo :: Encoder check parse ApiRoute PageName
To satisfy this type, we will build the Encoder
s for the backend and frontend and then replace
these holes with their respective Encoder
s. We won't dwell on these Encoder
s as they are built
using techniques we have already covered.
The Encoder
for each of these routes is defined using the enumEncoder
:
apiRouteEncoder :: (MonadError Text check, MonadError Text parse) => Encoder check parse ApiRoute PageName
apiRouteEncoder = enumEncoder $ \case
ApiRoute_Status -> (["status"], mempty)
ApiRoute_Version -> (["version"], mempty)
ApiRoute_Uptime -> (["uptime"], mempty)
appRouteEncoder :: (MonadError Text check, MonadError Text parse) => Encoder check parse AppRoute PageName
appRouteEncoder = enumEncoder $ \case
AppRoute_Register -> (["register"], mempty)
AppRoute_Contact -> (["contact"], mempty)
AppRoute_About -> (["about"], mempty)
These can replace the typed holes to complete our Encoder
with nested routes:
topLevelRouteEncoder
:: ( MonadError Text check
, MonadError Text parse
)
=> Encoder check parse (R TopLevel) PageName
topLevelRouteEncoder = pathComponentEncoder $ \case
TopLevel_API -> PathSegment "api" apiRouteEncoder
TopLevel_APP -> PathSegment "app" appRouteEncoder
Now all of these routes are organised in a way that makes sense for this application. Both the
nested and top-level Encoder
s are independent of one another and can be moved and altered without
impacting the other.
If AppRoute
is extended or made more complex, those changes are localised to the appRouteEncoder
.
If ApiRoute_Status
needs to be extended with its own nested routes then it can be separated into
its own Encoder
and again those changes are kept local to that Encoder
.
This modularity allows you to package entire route structures and their functionality into a standalone Haskell package that other people can import and use. Plugging it into their route structure where it makes sense for their application.
It's important to note here that when using your routes in their application, they could leverage the abstract definition of your routes and substitute their own encoding. This would allow them to replace functionality provided by your routes as per their needs without breaking existing links.
If you have repeated functionality in your application you could abstract out the differences and package the similarities, routes and all!
Suppose we want to use part of the route input as a value that is available for use at the destination. Let's assuming we want to add a homepage for every user. To do so we will need a route to represent a path to the homepage for every possible user ID, which means we parameterise this route by the type that represents every possible user ID.
Our application has Text
based user IDs, but we're responsible Haskellers so we will use a
newtype
to wrap this value up and keep our code cleaner:
newtype UserID = UserID { unUserID :: Text } deriving (Show, Eq)
makeWrapped ''UserID
That final line is template haskell from the lens
library that will generate a Wrapped
typeclass instance for us. This provides a useful Iso
for
us that makes writing the Encoder
, and using the UserID
type itself, easier.
Now we can define the route constructor as:
AppRoute_UserHome :: AppRoute UserID
This type represents one logical page per instantiation per possible user ID. In the address bar
this will appear as : /user/$UserID
. After adding this new route, we will start seeing a
warning that not all of our route constructors are accounted for in the Encoder
.
Extend that case
expression to include this new route:
AppRoute_UserHome -> PathSegment "user" _userhomeTodo
Similar to other routes, we have a static component, "user"
, and we have a typed hole that we need
to fill in. In this instance the typed hole as the following type:
_userhomeTodo :: Encoder check parse UserID PageName
In order to fill this typed hole we need to provide an Encoder
that turns a UserID
into a
PageName
. To do we're going to be composing existing Encoder
s. However the (.)
operator from
Prelude
is specialised to the function arrow type (->)
so we must import the more general
version from the Control.Category
module, because it uses the Category
typeclass constraint:
-- (.) from Prelude
(.) :: (b -> c) -> (a -> b) -> a -> c
-- (.) from Control.Category
(.) :: Category cat => cat b c -> cat a b -> cat a c
The Encoder
type is an instance of Category
so we need to use the more general function. To ensure
that the correct (.)
is imported use the following snippet:
-- This imports everything from Prelude except for (.)
import Prelude hiding ((.))
-- This imports everything from Control.Category
import Control.Category
If you use the (.)
from Prelude
to compose Encoder
s then you will end up with type errors
similar to the following:
• Couldn't match expected type ‘b0 -> c0’
with actual type ‘Encoder check0 parse0 [Text] PageName’
• In the first argument of ‘(.)’, namely ‘pathOnlyEncoder’
You can see that it expected something of type b0 -> c0
whereas we're wanting to compose
Encoder
s. Although we can think of Encoder
s like they are pure functions, the types are
distinct so we must use the correct function to compose them.
This package does not have any single Encoder
that will satisfy that type. What this package
provides is the smaller pieces we need to build this Encoder
. Small Encoder
s that do one thing
and do it correctly, such that we can compose these Encoder
to perform the steps we need.
Of the entire path: /user/$userid
. The user
section is nested in the app
segment, so the
Encoder
we need to build must target the $userid
segment, and only that segment, of the route.
This is done using the following Encoder
:
singlePathSegmentEncoder
:: ( Applicative check
, MonadError Text parse
)
=> Encoder check parse Text PageName
The singlePathSegmentEncoder
is an Encoder
that will ignore any query parameters and focus only
on a single segment in the path, disregarding any further segments. Using the intuition of thinking
about Encoder
like a pure function, this Encoder
is like a function from Text
to PageName
.
We will also need an Encoder
that to go from UserID
to Text
. This is where the Wrapped
typeclass we derived earlier comes in handy as that typeclass lets deal with all kinds of newtype
wrappers in a consistent way. We don't need their unique constructors and deconstructors, the
typeclass abstracts that away.
The following Encoder
leverages this typeclass to provide this Encoder
:
unwrappedEncoder
:: ( Wrapped a
, Applicative check
, Applicative parse
)
=> Encoder check parse a (Unwrapped a)
The type (Unwrapped a)
is an associated type from the Wrapped
typeclass that represents the type that
the newtype
wraps. In our case the type variable a
can be replaced with UserID
and Unwrapped UserID
with Text
. These replacements give us an Encoder
of the following type:
unwrappedEncoder :: _ => Encoder check parse UserID Text
Now that we have the two Encoder
s we need, we compose them together:
-- New route branch in the case expression
AppRoute_UserHome -> PathSegment "user" $ singlePathSegmentEncoder . unwrappedEncoder
Sometimes one parameter isn't enough and you need more information, these parameters might be grouped together in sequence because they're all associated with a single route. An example might be puzzles hidden in your application that provide three inputs, that when combined will take the user to the prize page:
/app/code/$solutionA/$solutionB/$solutionC
An alternative requirement might be that there is a set of nested routes that have a parameter and one of those nested routes requires its own parameters. Such as a route for a specific repository for a specific user:
/app/user/$userId/repository/$repoId
Each require a slightly different approach because the parameters appear at different positions along the route. This is important to note because when we have a 'non-parameter' route segment, such as "user", it indicates that a decision needs to be made as to how we proceed down the route hierarchy.
-
In the case of
code/$solutionA/$solutionB/$solutionC
, we expect the types of the abstract routes to stay consistent as we go fromA
toB
toC
. those types can't change. -
However, in
user/$userId
, we expect that the next path segment could be "repository", or if we had implemented other routes, it could be a different structure with its own subroutes.
In the first example, there are puzzles that provide three inputs and when combined into a single route will take the user to a specific page. This is only a single logical page but it has three required inputs and the path would appear in the address bar as follows:
/app/code/$solutionA/$solutionB/$solutionC
Defining the first part of our constructor leads to an interesting question: What is the type that
this constructor will be parameterised by? Within obelisk-route
is the type alias (:.)
which is
an alias for a tuple (,).
This allows us to express multiple possibly different types as the
type for a route constructor. Assuming that our puzzle solutions will be an Int
value, a Text
value, and a final Int
value, then our route is defined as:
AppRoute_CodePrize :: AppRoute (Int :. Text :. Int)
Which is equivalent to the following but easier to read:
AppRoute_CodePrize :: AppRoute (Int, (Text, Int))
Now it is a matter of building our Encoder
to process the individual segments and package them all
up as required. To handle the (:.)
portion of the encoding process, we're going to use the
pathParamEncoder
that has the following type:
pathParamEncoder
:: ( ... )
=> Encoder check parse item Text
-> Encoder check parse rest PageName
-> Encoder check parse (item :. rest) PageName
This function requires two Encoder
s, one for the initial parameter item
, and another for the
rest
of the route. In this case it will be another pathParamEncoder
because the second parameter
is another tuple. Lets extend the appRouteEncoder
with another branch on the case
expression:
AppRoute_CodePrize -> PathSegment "code" $ pathParamEncoder
_itemEncoder
_restEncoder
The types of the two typed holes are :
_itemEncoder :: Encoder check parse Int Text
_restEncoder :: Encoder check parse (Text :. Int) PageName
We're going to lean on the Show
& Read
instances for Int
because for Int
these typeclasses
are inverses of one another, meaning that we don't lose any information about the Int
value. So
we're able to leverage these instances to create a safely bidirectional Encoder
. To do this, we
will use the unsafeTshowEncoder
:
unsafeTshowEncoder
:: ( Show a
, Read a
, Applicative check
, MonadError Text parse
)
=> Encoder check parse a Text
This Encoder
that will show
the value of type a
when creating the URL segment, and read
when
trying to turn that segment of the URL into a value of type a
. This is only safe to do because we
know that the Show
and Read
instances for Int
are inverses. Refer to the Haddock
documentation for unsafeTshowEncoder
for more information.
Replace the _itemEncoder
with this Encoder
:
AppRoute_CodePrize -> PathSegment "code" $ pathParamEncoder
unsafeTshowEncoder
_restEncoder
Now onto the next part of our Encoder
. Checking the _restEncoder
typed hole, we can see that it
has the following type:
_restEncoder :: Encoder (Either Text) (Either Text) (Text :. Int) PageName
This Encoder
is for the next two parameters of our tuple, so we can start building this Encoder
using the pathParamEncoder
. This once again reduces the work to two separate Encoder
s that do
the minimum we need, and the pathParamEncoder
handles the tupling for us:
AppRoute_CodePrize -> PathSegment "code" $ pathParamEncoder
unsafeTshowEncoder
$ pathParamEncoder
_itemEncoder
_restEncoder
We repeat the process of working through the typed holes to build or find the Encoder
s we need to
define our route. The _itemEncoder
is now the second parameter to our tuple, which is a Text
value so the typed hole has the following type:
_itemEncoder :: Encoder check parse Text Text
Remembering that an Encoder check parse a b
may be thought of as a function of type a
to b
. In
this case it would be equivalent to a function of type a
to a
as both types are the same. This
is equivalent to the id
function, which is part of the Category
typeclass, of which Encoder
has an instance. Thus the Encoder
is id
:
AppRoute_CodePrize -> PathSegment "code" $ pathParamEncoder
unsafeTshowEncoder
$ pathParamEncoder
id
_restEncoder
The final _restEncoder
typed hole has the following type, as the last value in the tuple and the
path itself:
_restEncoder :: Encoder check parse Int PageName
We will use unsafeTshowEncoder
again, but this time we compose it with the
singlePathSegmentEncoder
, which has the following type:
singlePathSegmentEncoder :: _ => Encoder check parse Text PageName
Making the final Encoder
for the (Int :. Text :. Int)
type:
AppRoute_CodePrize -> PathSegment "code" $ pathParamEncoder
unsafeTshowEncoder
$ pathParamEncoder
id
(singlePathSegmentEncoder . unsafeTshowEncoder)
Another Encoder
that we could used as the final _restEncoder
is the unsafeShowEncoder
:
unsafeShowEncoder
:: ( MonadError Text parse
, Read a
, Show a
, Applicative check
)
=> Encoder check parse a PageName
It uses the Show
and Read
instances by returns a PageName
. This one Encoder
can be used in
place of the composition of:
singlePathSegmentEncoder . unsafeTshowEncoder
It's not the use of Show
and Read
that is unsafe, it's that if the types change and the
instances exist then the compiler will automatically use those instances. Which might not be what
you want.
Another situation where you might have multiple parameters is when there are nested routes and an upper level of the nesting has a parameter along with one or more of the nested routes:
/app/user/$userId/repository/$repoId
For this example we will use the following newtypes
and routes:
newtype UserID = UserID { unUserID :: Text } deriving (Show, Eq)
makeWrapped ''UserId
newtype RepoId = RepoID { unRepoID :: Text } deriving (Show, Eq)
makeWrapped ''RepoId
data UserRoute :: * -> * where
UserRoute_Home :: UserRoute ()
UserRoute_Repository :: UserRoute RepoId
data AppRoute :: * -> * where
AppRoute_CodePrize :: AppRoute (Int :. Text :. Int)
AppRoute_User :: AppRoute (UserID :. R UserRoute)
Of most interest to us is the AppRoute
definition. The AppRoute_UserHome
constructor is changed
to be AppRoute_User
, because there is more than one route under each UserID
. Looking at its type
(UserID :. R UserRoute)
we can see that this route will need a UserID
followed by one of the
routes in UserRoute
.
We will need to updated the appRouteEncoder
because the name and the type of our constructor has changed:
appRouteEncoder :: _ => Encoder check parse (R AppRoute) PageName
appRouteEncoder = pathComponentEncoder $ \case
AppRoute_User -> PathSegment "user" $ _f
We're building an Encoder
for set of routes (R UserRoute
) as we've done before at the same time
is retrieving the UserID
parameter that the AppRoute_UserHome
constructor originally
required. As before we use the pathParamEncoder
to manage the tupling for us:
appRouteEncoder :: _ => Encoder check parse (R AppRoute) PageName
appRouteEncoder = pathComponentEncoder $ \case
AppRoute_User -> PathSegment "user" $ pathParamEncoder
-- Encoder check parse UserID Text
_userIdEncoder
-- Encoder check parse (R UserRoute) PageName
_userRouteEncoder
Constructing the Encoder
for the UserID
is the same as we have before when dealing with a
newtype
that has an instance of Wrapped
:
appRouteEncoder :: _ => Encoder check parse (R AppRoute) PageName
appRouteEncoder = pathComponentEncoder $ \case
AppRoute_User -> PathSegment "user" $ pathParamEncoder
-- Encoder check parse UserID Text
(unsafeTshowEncoder . unwrappedEncoder)
-- Encoder check parse (R UserRoute) PageName
userRouteEncoder
Now we complete the route by defining the Encoder
for R UserRoute
:
userRouteEncoder :: _ => Encoder check parse (R UserRoute) PageName
userRouteEncoder = pathComponentEncoder $ \case
UserRoute_Repository -> PathSegment "repository" $ singlePathSegmentEncoder
. unsafeTshowEncoder
. unwrappedEncoder
This package has the capability to collect the various query parameters that may be included as part of a route. Query parameters can be collected on their own:
/app/picture-of-the-day?width=800&height=600
Or they may be collected along with other route parameters:
/app/user/$userid/repository/$repoid?commit=abc1234
Displaying a 'picture of the day' that allows the user to set the height and width of the image is an example of a route that only has a single logical page but is configurable through query parameters:
/app/picture-of-the-day?width=800&height=600
The type of this route will only have arguments that represent the query parameters, without
duplicates so the type of our route argument will be a Map
:
AppRoute_POTD :: AppRoute (Map Text (Maybe Text))
The types of the values of the Map
are Maybe Text
because a parameter may not require an
explicit value, including the parameter at all could be sufficient.
For our type we have no additional parameters and no nesting, we're only interested in the query
parameters. The Encoder
for this task is the queryOnlyEncoder
:
queryOnlyEncoder :: _ => Encoder check parse (Map Text (Maybe Text)) PageName
Assuming an appRouteEncoder :: Encoder check parse (R AppRoute) PageName
that is defined using the
pathComponentEncoder
, we will extend the case
expression:
AppRoute_POTD -> PathSegment "picture-of-the-day" queryOnlyEncoder
Sometimes query parameters are included alongside other route parameters. Taking the "repository" route from the Multiple Parameters section, if we wanted to be able to view a specific commit. It doesn't necessarily make sense to make an entirely new page for that, as it is a configurable state of the existing page. So we're able to take the existing route type:
UserRoute_Repository :: UserRoute RepoId
Then modify it to accept a Map
of query parameters as well as the RepoId
:
UserRoute_Repository :: UserRoute (RepoId :. Map Text (Maybe Text))
Note the reuse of the (:.)
operator to combine the two route inputs. This provides a hint as to
how we will modify the existing Encoder
for this route:
UserRoute_Repository -> PathSegment "repository"
$ singlePathSegmentEncoder . unsafeTshowEncoder . unwrappedEncoder
To now require the RepoId
and have the capability to handle query parameters:
UserRoute_Repository -> PathSegment "repository" $ pathParamEncoder repoIdEncoder queryOnlyEncoder
where
repoIdEncoder :: (..) => Encoder check parse RepoId Text
repoIdEncoder = unsafeTshowEncoder . unwrappedEncoder
We factor out the Encoder
for the RepoId
to keep things a bit more organised, which is more
general than the type signature suggests, but we're keeping things fixed for the moment. Pass this
Encoder
as the first argument to pathParamEncoder
then use the queryOnlyEncoder
to take any
query parameters and turn them into the Map
we require.
This route changes to now have the following possible representation in the address bar:
/app/user/$userid/repository/$repoid?commit=abc1234