(Ab)using do notation for a Wai DSL

Recently I was thinking it would be nice to have something like Rack’s URLMap for Wai. If you haven’t come across it, it lets you combine Rack applications easily, based on the request path info or host. For example, if you wanted to have a bug tracking application under “/bugs”, and a helpdesk application under “/helpdesk”, and your main website under “/”, you might have:

Rack::URLMap.new do
  map "/bugs" do
    run BugTrackingApp
  end

  map "/helpdesk" do
    run HelpdeskApp
  end

  map "/" do
    run MainSiteApp
  end
end

This URLMap can then become a single Rack application.

The first question is how we are going to represent this data structure in Haskell. Intuitively, it seems sensible that a request should start top of the block, trying to match requests, and work its way downwards. A Data.Map certainly won’t do, since the order we get the keys out will probably not be the same as the order they went in. Using the Ruby example above, this could mean that a request meant for either of the applications on the sub-URIs might end up being sent to the main site – not good.

So we need an ordered lookup list, mapping request paths to Wai Applications. This is what I used:

type Path = [Text]
type UrlMap = [(Path, Application)]

Strict text makes things easier for us, since all we need to do to get the request path as a list of strict Text values is call pathInfo on it. The UrlMap type is also convenient because there is already a Prelude function which can do the lookup for us: lookup; which takes a key and a lookup list, and possibly returns the value associated with that key (that is, Eq a => a -> [(a,b)] -> Maybe b).

If we want to use do notation, we need a monad to store this data. It should be able to append information to a data structure which can then be extracted by running the computation. Sounds like a job for the Writer monad, from Control.Monad.Writer:

type UrlMapM = Writer UrlMap ()

We don’t care about the result of the computation, just the value that was built up over the course of it, so we use unit () as the second type argument.

We don’t want the users of our URL mapper to have to know the implementation details, so let’s provide some functions to abstract them away.

mount :: Path -> Application -> UrlMapM
mount prefix app = tell [(prefix, app)]

runUrlMapM :: UrlMapM -> UrlMap
runUrlMapM = execWriter

So now we can do this:

urlMapM :: UrlMapM
urlMapM = do
    mount ["bugs"] bugTrackingApp
    mount ["helpdesk"] helpdeskApp
    mount [] mainSiteApp

urlmap :: UrlMap
urlmap = runUrlMapM urlMapM

Now to turn an UrlMap into an Application. When we’re trying to match a request with an application, we should work our way down the list, seeing if the path an application is mounted under is a prefix of the request path; if so, the prefix should be removed, and the request should be sent to that application.

try :: Eq a
    => [a]              -- ^ Path info of request
    -> [([a], b)]       -- ^ The UrlMap
    -> Maybe ([a], b)   -- ^ A pair consisting of the remainder of the path
                        -- after removing the matching prefix and the
                        -- relevant application, or Nothing.
try xs tuples = foldl go Nothing tuples
  where
    go (Just x) _     = Just x
    go _ (prefix, y)  = fmap (\xs' -> (xs', y)) $ stripPrefix prefix xs

stripPrefix from Data.List takes two lists, and, if the first is a prefix of the second, removes the prefix from the second and returns it as a Just value. If not, it returns Nothing.

Here I’m using the Functor instance for Maybe; if stripPrefix returns a Just value, then fmap will apply the lambda function to the value inside the Just. If it returns Nothing, then fmap will just return Nothing.

Now we just need to combine this function with a Wai Request and an UrlMap:

toApplication :: UrlMap -> Application
toApplication urlmap = \req ->
    case try (pathInfo req) urlmap of
        Just (newPath, app) ->
            app $ req { pathInfo = newPath
                      , rawPathInfo = makeRaw newPath
                      }

        Nothing ->
            return $ responseLBS
                status500
                [("content-type", "text/plain")]
                ("WaiUrlMapper: no routes matched. Consider using " <>
                 "an empty path for the last mapping in the 'do' block.\n")

    where
    makeRaw :: [Text] -> B.ByteString
    makeRaw = ("/" `B.append`) . T.encodeUtf8 . T.intercalate "/"

One more nice helper function:

mapUrls :: UrlMapM -> Application
mapUrls = toApplication . runUrlMapM

I’m not sure if it’s absolutely necessary to modify both the pathInfo and the rawPathInfo, but it seems safer to do so.

Here’s the full code which I’m now using. It has a couple of additions: namely, a ToApplication typeclass so that you can mount another UrlMapM under a request path, and also a couple of extra helper functions which are just little wrappers around mount.

{-# LANGUAGE OverloadedStrings, TypeSynonymInstances, FlexibleInstances #-}
module WaiUrlMapper where

import Control.Monad.Writer
import Data.Monoid
import Data.Char
import Data.List
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as BL
import Network.HTTP.Types
import Network.Wai
import Network.Wai.Handler.Warp

type Path = [Text]
type UrlMap = [(Path, Application)]
type UrlMapM = Writer UrlMap ()

mount :: ToApplication a => Path -> a -> UrlMapM
mount prefix thing = tell [(prefix, toApplication thing)]

-- A little helper function, since most of the time, apps are mounted under
-- a single path segment.
mount' :: ToApplication a => Text -> a -> UrlMapM
mount' prefix thing = mount [prefix] thing

-- Another little helper function. Use this for the last mounted
-- application in the block, to avoid 500 errors from none matching.
mountRoot :: ToApplication a => a -> UrlMapM
mountRoot = mount []

runUrlMapM :: UrlMapM -> UrlMap
runUrlMapM = execWriter

try :: Eq a
    => [a]        -- ^ Path info of request
    -> [([a], b)] -- ^ List of applications to match
    -> Maybe ([a], b)
try xs tuples = foldl go Nothing tuples
    where
        go (Just x) _     = Just x
        go _ (prefix, y)  = stripPrefix prefix xs >>= \xs' -> return (xs', y)

class ToApplication a where
    toApplication :: a -> Application

instance ToApplication Application where
    toApplication = id

instance ToApplication UrlMap where
    toApplication urlMap = \req ->
        case try (pathInfo req) urlMap of
            Just (newPath, app) ->
                app $ req { pathInfo = newPath
                          , rawPathInfo = makeRaw newPath
                          }
            Nothing ->
                return $ responseLBS
                    status500
                    [("content-type", "text/plain")]
                    ("WaiUrlMapper: no routes matched. Consider using " <>
                     "'mountRoot for the last mapping in the 'do' block.\n")

        where
        makeRaw :: [Text] -> B.ByteString
        makeRaw = ("/" `B.append`) . T.encodeUtf8 . T.intercalate "/"

instance ToApplication UrlMapM where
    toApplication = toApplication . runUrlMapM

mapUrls :: UrlMapM -> Application
mapUrls = toApplication

-- And here's some example code which uses it:

trivialApp :: BL.ByteString -> Application
trivialApp msg req = return $
    responseLBS
        status200
        [("content-type", "text/plain")]
        (msg <>
            "\nrawPathInfo: " <> strictToLazy (rawPathInfo req) <>
            "\npathInfo: " <> stringToLBS (show $ pathInfo req) <>
            "\n")

    where
    strictToLazy :: B.ByteString -> BL.ByteString
    strictToLazy = BL.fromChunks . (: [])

    stringToLBS :: String -> BL.ByteString
    stringToLBS = BL.pack . map (fromIntegral . ord)

bugsApp, helpdeskApp, apiV1, apiV2, mainApp :: Application
bugsApp     = trivialApp "this is the bugs app"
helpdeskApp = trivialApp "this is the helpdesk app"
apiV1       = trivialApp "api, version 1"
apiV2       = trivialApp "api, version 2"
mainApp     = trivialApp "this is the main site"

urlmap :: UrlMapM
urlmap = do
    mount' "bugs" bugsApp
    mount' "helpdesk" helpdeskApp
    mount' "api" $ do
        -- Note that (by design) this cannot 'fall up' into the outer do
        -- block. So if we get here, it will have to either match the mapping
        -- below, or we'll get a 500 error.
        mount' "v1" apiV1
        mount' "v2" apiV2
    mountRoot mainApp

main :: IO ()
main = run 3000 $ mapUrls urlmap