Route attributes

Route attributes allow you to set some metadata on each of your routes, in the routes description itself. The syntax is trivial: just an exclamation point followed by a value. Using it is also trivial: just use the routeAttrs function.

It’s easiest to understand how it all fits together, and when you might want it, with a motivating example. The case I personally most use this for is annotating administrative routes. Imagine having a website with about 12 different admin actions. You could manually add a call to requireAdmin or some such at the beginning of each action, but:

  1. It’s tedious.

  2. It’s error prone: you could easily forget one.

  3. Worse yet, it’s not easy to notice that you’ve missed one.

Modifying your isAuthorized method with an explicit list of administrative routes is a bit better, but it’s still difficult to see at a glance when you’ve missed one.

This is why I like to use route attributes for this: you add a single word to each relevant part of the route definition, and then you just check for that attribute in isAuthorized. Let’s see the code!

  1. {-# LANGUAGE MultiParamTypeClasses #-}
  2. {-# LANGUAGE OverloadedStrings #-}
  3. {-# LANGUAGE QuasiQuotes #-}
  4. {-# LANGUAGE TemplateHaskell #-}
  5. {-# LANGUAGE TypeFamilies #-}
  6. import Data.Set (member)
  7. import Data.Text (Text)
  8. import Yesod
  9. import Yesod.Auth
  10. import Yesod.Auth.Dummy
  11. data App = App
  12. mkYesod "App" [parseRoutes|
  13. / HomeR GET
  14. /unprotected UnprotectedR GET
  15. /admin1 Admin1R GET !admin
  16. /admin2 Admin2R GET !admin
  17. /admin3 Admin3R GET
  18. /auth AuthR Auth getAuth
  19. |]
  20. instance Yesod App where
  21. authRoute _ = Just $ AuthR LoginR
  22. isAuthorized route _writable
  23. | "admin" `member` routeAttrs route = do
  24. muser <- maybeAuthId
  25. case muser of
  26. Nothing -> return AuthenticationRequired
  27. Just ident
  28. -- Just a hack since we're using the dummy module
  29. | ident == "admin" -> return Authorized
  30. | otherwise -> return $ Unauthorized "Admin access only"
  31. | otherwise = return Authorized
  32. instance RenderMessage App FormMessage where
  33. renderMessage _ _ = defaultFormMessage
  34. -- Hacky YesodAuth instance for just the dummy auth plugin
  35. instance YesodAuth App where
  36. type AuthId App = Text
  37. loginDest _ = HomeR
  38. logoutDest _ = HomeR
  39. getAuthId = return . Just . credsIdent
  40. authPlugins _ = [authDummy]
  41. maybeAuthId = lookupSession credsKey
  42. getHomeR :: Handler Html
  43. getHomeR = defaultLayout $ do
  44. setTitle "Route attr homepage"
  45. [whamlet|
  46. <p>
  47. <a href=@{UnprotectedR}>Unprotected
  48. <p>
  49. <a href=@{Admin1R}>Admin 1
  50. <p>
  51. <a href=@{Admin2R}>Admin 2
  52. <p>
  53. <a href=@{Admin3R}>Admin 3
  54. |]
  55. getUnprotectedR, getAdmin1R, getAdmin2R, getAdmin3R :: Handler Html
  56. getUnprotectedR = defaultLayout [whamlet|Unprotected|]
  57. getAdmin1R = defaultLayout [whamlet|Admin1|]
  58. getAdmin2R = defaultLayout [whamlet|Admin2|]
  59. getAdmin3R = defaultLayout [whamlet|Admin3|]
  60. main :: IO ()
  61. main = warp 3000 App

And it was so glaring, I bet you even caught the security hole about Admin3R.

Alternative approach: hierarchical routes

Another approach that can be used in some cases is hierarchical routes. This allows you to group a number of related routes under a single parent. If you want to keep all of your admin routes under a single URL structure (e.g., /admin), this can be a good solution. Using them is fairly simple. You need to add a line to your routes declaration with a path, a name, and a colon, e.g.:

  1. /admin AdminR:

Then, you place all children routes beneath that line, and indented at least one space, e.g.:

  1. /1 Admin1R GET
  2. /2 Admin2R GET
  3. /3 Admin3R GET

To refer to these routes using type-safe URLs, you simply wrap them with the AdminR constructor, e.g. AdminR Admin1R. Here is the previous route attribute example rewritten to use hierarchical routes:

  1. {-# LANGUAGE MultiParamTypeClasses #-}
  2. {-# LANGUAGE OverloadedStrings #-}
  3. {-# LANGUAGE QuasiQuotes #-}
  4. {-# LANGUAGE TemplateHaskell #-}
  5. {-# LANGUAGE TypeFamilies #-}
  6. import Data.Set (member)
  7. import Data.Text (Text)
  8. import Yesod
  9. import Yesod.Auth
  10. import Yesod.Auth.Dummy
  11. data App = App
  12. mkYesod "App" [parseRoutes|
  13. / HomeR GET
  14. /unprotected UnprotectedR GET
  15. /admin AdminR:
  16. /1 Admin1R GET
  17. /2 Admin2R GET
  18. /3 Admin3R GET
  19. /auth AuthR Auth getAuth
  20. |]
  21. instance Yesod App where
  22. authRoute _ = Just $ AuthR LoginR
  23. isAuthorized (AdminR _) _writable = do
  24. muser <- maybeAuthId
  25. case muser of
  26. Nothing -> return AuthenticationRequired
  27. Just ident
  28. -- Just a hack since we're using the dummy module
  29. | ident == "admin" -> return Authorized
  30. | otherwise -> return $ Unauthorized "Admin access only"
  31. isAuthorized _route _writable = return Authorized
  32. instance RenderMessage App FormMessage where
  33. renderMessage _ _ = defaultFormMessage
  34. -- Hacky YesodAuth instance for just the dummy auth plugin
  35. instance YesodAuth App where
  36. type AuthId App = Text
  37. loginDest _ = HomeR
  38. logoutDest _ = HomeR
  39. getAuthId = return . Just . credsIdent
  40. authPlugins _ = [authDummy]
  41. maybeAuthId = lookupSession credsKey
  42. getHomeR :: Handler Html
  43. getHomeR = defaultLayout $ do
  44. setTitle "Route attr homepage"
  45. [whamlet|
  46. <p>
  47. <a href=@{UnprotectedR}>Unprotected
  48. <p>
  49. <a href=@{AdminR Admin1R}>Admin 1
  50. <p>
  51. <a href=@{AdminR Admin2R}>Admin 2
  52. <p>
  53. <a href=@{AdminR Admin3R}>Admin 3
  54. |]
  55. getUnprotectedR, getAdmin1R, getAdmin2R, getAdmin3R :: Handler Html
  56. getUnprotectedR = defaultLayout [whamlet|Unprotected|]
  57. getAdmin1R = defaultLayout [whamlet|Admin1|]
  58. getAdmin2R = defaultLayout [whamlet|Admin2|]
  59. getAdmin3R = defaultLayout [whamlet|Admin3|]
  60. main :: IO ()
  61. main = warp 3000 App

Hierarchical routes with attributes

Of course, you can mix the two approaches. Children of a hierarchical route will inherit the attributes of their parents, e.g.:

  1. /admin AdminR !admin:
  2. /1 Admin1R GET !1
  3. /2 Admin2R GET !2
  4. /3 Admin3R GET !3

AdminR Admin1R has the admin and 1 attributes.

With this technique, you can use the admin attributes in the isAuthorized function, like in the first example. You are also sure that you won’t forget any attributes as we did with Admin3R. Compared to the original code corresponding to the hierarchical route, this method has no real benefit : both methods being somehow equivalent. We replaced the pattern matching on (AdminR _) with "admin" `member` routeAttrs route. However, the benefit becomes more obvious when the admin pages are not all grouped under the same url structures but belong to different subtrees, e.g:

  1. /admin AdminR !admin:
  2. /1 Admin1R GET
  3. /2 Admin2R GET
  4. /3 Admin3R GET
  5. /a AR !a:
  6. /1 A1R GET
  7. /2 A2R GET
  8. /admin AAdminR !admin:
  9. /1 AAdmin1R GET
  10. /2 AAdmin2R GET

The pages under /admin and /a/admin have all the admin attribute and can be checked using "admin" `member` routeAttrs route. Pattern matching on (AdminR _) will not work for this example and only match /admin/* routes but not /a/admin/\*