The other day, I was thinking of ways I could prevent a common mistake I make when registering routes for my vidtracker project: not registering a route handler. Even though I share the type definitions used for routes between my frontend and backend, it doesn't help if I forget to actually register a handler for the route.
Luckily, we have the technology to do this with the following:
- Share the record label used between a record of my API Routes and Route Handlers. By using the same label, I know which is to be paired with which.
- Use
RowToList
to convert my row types to type-level lists so I can iterate them. I also take advantage of the fact that the keys are sorted when converted toRowList
s. - Use
RowCons
to constrain that the routes and handlers records both have an element for the given single labels.
My Route Types
First, I have my own kind RequestMethod
, for which I have two data types:
foreign import kind RequestMethod
foreign import data GetRequest :: RequestMethod
foreign import data PostRequest :: RequestMethod
This is then incorporated into my Route
data type:
data Route (method :: RequestMethod) req res (url :: Symbol) = Route
As you can see, Route
is a phantom type where I have an associated method, a request type, response type, and a Symbol
for the URL. I then have two convenience type aliases:
type GetRoute = Route GetRequest Void
type PostRoute = Route PostRequest
The first alias prevents me from accidentally using the req
parameter to try to read the response body, preventing me from writing invalid code that tries to read the request body of a GET request. This is just one example of how phantom types can be incredibly useful for preventing mistakes and save you from having to do extra manual checking work (if a computer can be told to do it fairly easily, why not, right?).
The second is a simple alias allowing me to use all of the remaining parameters.
Then I have my route definitions in a record like so:
apiRoutes ::
{ files :: GetRoute (Array Path) "/api/files"
, watched :: GetRoute (Array WatchedData) "/api/watched"
, getIcons :: PostRoute GetIconsRequest Success "/api/get-icons"
, update :: PostRoute FileData (Array WatchedData) "/api/update"
, open :: PostRoute OpenRequest Success "/api/open"
, remove :: PostRoute RemoveRequest Success "/api/remove"
}
apiRoutes =
{ files: Route
, watched: Route
, getIcons: Route
, update: Route
, open: Route
, remove: Route
}
So while the runtime representation isn't very interesting, the static information we get from the phantom type parameters is more than enough for us to statically prepare code paths.
Iterating Routes and Handlers
So from (2) above, we can write our exposed function that works with any Monad:
registerRoutes :: forall routes handlers routesL handlersL m
. RowToList routes routesL
=> RowToList handlers handlersL
=> Monad m
=> RoutesHandlers routesL handlersL routes handlers m
=> Record routes
-> Record handlers
-> m Unit
registerRoutes routes handlers =
registerRoutesImpl
(RLProxy :: RLProxy routesL)
(RLProxy :: RLProxy handlersL)
routes
handlers
As usual, routes
and handlers
are the row type # Type
from the routes and handlers records passed in, for which I create RowList
s by using RowToList
. Then I use the method for the implementation and pass in the records for my RoutesHandlers
type class defined as such:
class RoutesHandlers
(routesL :: RowList)
(handlersL :: RowList)
(routes :: # Type)
(handlers :: # Type)
m
where
registerRoutesImpl :: forall proxy
. Monad m
=> proxy routesL
-> proxy handlersL
-> Record routes
-> Record handlers
-> m Unit
So our type class takes in the RowList
s it can use to match instances and iterate with, the row types for the actual records, and our monad. The biggest reason we explicitly pass in the row types is because otherwise, I would have to remove fields from my records to pass them down, whereas I only need to get fields out of them. If this note doesn't make sense, come back to it after you've read through this blog post and some other RowToList resources.
So how do these get implemented?
Nil/Nil case
So I want to make sure that when I go through the routes and handlers, I should reach the Nil
case on both at the same time. By defining a Nil Nil
case and not Nil xs
or xs Nil
, I am able to enforce this (otherwise there is no matching instance for this case). And so in the case of Nil Nil
, I need to do nothing, so the instance is defined as such:
instance routesHandlersNil :: RoutesHandlers Nil Nil trash1 trash2 m where
registerRoutesImpl _ _ _ _ = pure unit
Cons name/Cons name case
So since the RowList
is sorted by the label, we have guaranteed pairwise iteration, which is quite convenient for (1), that we use the same label. By writing our instance head to use the same variable name
, we can make sure that the instance matches when the name
s are the same:
instance routesHandlersCons ::
( RoutesHandlers rTail hTail routes handlers m
, IsSymbol name
, RowCons name handler trash1 handlers
, RowCons name route trash2 routes
, RegisterHandler route handler m
) => RoutesHandlers
(Cons name route rTail)
(Cons name handler hTail)
routes
handlers
m where
registerRoutesImpl _ _ routes handlers = do
registerHandlerImpl (get nameP routes) (get nameP handlers)
registerRoutesImpl (RLProxy :: RLProxy rTail) (RLProxy :: RLProxy hTail) routes handlers
where
nameP = SProxy :: SProxy name
From top to bottom:
- The tails of the routes and handlers
RowList
s, along with the original record row types and the Monadm
are passed down to run through the rest of the list. - The
name
needs to be aSymbol
for some of our operations below. - We constrain with
RowCons
that there is a field with a labelname
and typehandler
, added on to atrash1
row that we don't really care about, which forms thehandlers
row. Put another way, we ensure thatname :: handler
exists in ourhandlers
row type. - The same is done with
route
androutes
. - Then we use our other type class
RegisterHandler
, which providesregisterHandlerImpl
.
The body here does two things. First, it uses RegisterHandler
to register the route
and the handler
that we're getting out of the records. Then, it uses RoutesHandlers
to operate on the rest of the routes and handlers. Now to look more at RegisterHandler
.
Registering our Handlers
For our type class here, we need a single route, its associated handler, and the monad that we're operating with.
class RegisterHandler route handler m
| route -> handler m
where
registerHandlerImpl :: route -> handler -> m Unit
I use the functional dependencies here to make sure that the correct instance is chosen based on the route. I do this because I need to handle the two cases I had above: the POST and GET cases.
As I wrote earlier, the handlers correspond to the route definition:
route: Route PostRequest FileData (Array WatchedData) "/api/update"
handler: FileData -> m (Array WatchedData)
route: Route GetRequest Void (Array WatchedData) "/api/watched"
handler: m (Array WatchedData)
So with this information, we can write the instances accordingly.
POST
For ease of use and implementation, I provide my handlers as functions for Aff
, and use the purescript-express
AppM
for the monad returned.
instance registerHandlerPost ::
( IsSymbol url
, ReadForeign req
, WriteForeign res
) => RegisterHandler
(Route PostRequest req res url)
(req -> Aff (express :: EXPRESS | e) res)
(AppM (express :: EXPRESS | e)) where
registerHandlerImpl route handler =
E.post route' handler'
where
route' = reflectSymbol (SProxy :: SProxy url)
handler' = do
body <- getBody
case runExcept (read body) of
Right (r :: req) -> do
response <- liftAff $ handler r
sendJson $ write response
Left e -> do
setStatus 400
sendJson $ write {error: show e}
So the constraints are the IsSymbol url
again to reflect the URL to use to register the route, ReadForeign req
for parsing the request JS object to the type I want to work with, and WriteForeign res
for encoding the response type to the JS object expected.
Note that in the instance head, I am doing the matching that I wrote about again: the req
and res
types are in the corresponding positions such that the handler must be correctly typed and implemented for the route. This lets me avoid any problems with accidentally returning the wrong type from a given route.
The implementation, on the other hand, is all normal code you'd write for purescript-express
.
GET
With the POST instance written, the GET instance comes out about the same:
instance registerHandlerGet ::
( IsSymbol url
, WriteForeign res
) => RegisterHandler
(Route GetRequest Void res url)
(Aff (express :: EXPRESS | e) res)
(AppM (express :: EXPRESS | e)) where
registerHandlerImpl route handler =
E.get route' handler'
where
route' = reflectSymbol (SProxy :: SProxy url)
handler' = do
response <- liftAff handler
sendJson $ write response
In this case, as req
does not exist for GET requests, the handler provided doesn't need to be anything more than Aff res
.
Usage
And that's about it, really. The actual usage then looks like this:
routes config = do
-- ...
registerRoutes
apiRoutes
{ files: getFiles config
, watched: getWatchedData config
, getIcons: getIconsData config
, update: updateWatched config
, open: openFile config
, remove: removeFile config
}
-- ...
And while we could fix this up in a variety of ways such as using ReaderT
with this config
for Aff
or writing another fancy function to apply config
to a heterogenous functions of config
, really, we don't need to for now, since this is not really error-prone as you can't forget to apply config
.
Now all the apiRoutes
can be called from my client and the handlers are guaranteed to be implemented here.
Conclusion
People have been asking me for a while about how they might go about using row types and these strange RowCons
and RowToList
tools and such, but I hope this provides more examples of how some of this stuff works.
If you have any questions about this or anything else PureScript, please ask on the FP Slack #purescript or #purescript-beginners channel, on r/purescript, or me directly on Twitter@jusrin00!
Links
- https://github.com/justinwoo/vidtracker/
- some extra record functions with various implementations and examples https://github.com/justinwoo/purescript-record-extra
- thread about RowToList https://www.reddit.com/r/purescript/comments/6mss5o/new_in_purescript_0116_rowtolist/