airship-0.9.4: src/Airship/Internal/Route.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_HADDOCK hide #-}
module Airship.Internal.Route
( RoutingSpec
, Route
, RouteLeaf
, RoutedResource(..)
, Trie
, root
, var
, star
, (</>)
, (#>)
, (#>=)
, runRouter
, route
, routeText
) where
import Airship.Resource as Resource
import Control.Monad.Writer.Class (MonadWriter, tell)
import qualified Data.ByteString as B
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Char8 as BC8
import Data.HashMap.Strict (HashMap, fromList)
import qualified Data.List as L (foldl')
import Data.Maybe (isNothing)
import Data.Semigroup (Semigroup, (<>))
import Data.Monoid (Monoid)
import Data.Text (Text)
import qualified Data.Text as T (intercalate, cons)
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
import Data.Trie (Trie)
import qualified Data.Trie as Trie
#if __GLASGOW_HASKELL__ < 710
import Control.Applicative
#endif
import Control.Monad.Writer (Writer, WriterT (..), execWriter)
import Data.String (IsString, fromString)
-- | 'Route's represent chunks of text used to match over URLs.
-- You match hardcoded paths with string literals (and the @-XOverloadedStrings@ extension),
-- named variables with the 'var' combinator, and wildcards with 'star'.
newtype Route = Route { getRoute :: [BoundOrUnbound] } deriving (Show, Semigroup, Monoid)
routeText :: Route -> Text
routeText (Route parts) =
T.cons '/' $ T.intercalate "/" ((boundOrUnboundText <$> parts))
data BoundOrUnbound = Bound Text
| Var Text
| RestUnbound deriving (Show)
boundOrUnboundText :: BoundOrUnbound -> Text
boundOrUnboundText (Bound t) = t
boundOrUnboundText (Var t) = ":" <> t
boundOrUnboundText (RestUnbound) = "*"
instance IsString Route where
fromString s = Route [Bound (fromString s)]
data RoutedResource m
= RoutedResource Route (Resource m)
data RouteLeaf m = RouteMatch (RoutedResource m) [Text]
| RVar
| RouteMatchOrVar (RoutedResource m) [Text]
| Wildcard (RoutedResource m)
-- | Turns the list of routes in a 'RoutingSpec' into a 'Trie' for efficient
-- routing
runRouter :: RoutingSpec m a -> Trie (RouteLeaf m)
runRouter routes = toTrie $ execWriter (getRouter routes)
where
-- Custom version of Trie.fromList that resolves key conflicts
-- in the desired manner. In the case of duplicate routes the
-- routes specified first are favored over any subsequent
-- specifications.
toTrie = L.foldl' insertOrReplace Trie.empty
insertOrReplace t (k, v) =
let newV = maybe v (mergeValues v) $ Trie.lookup k t
in Trie.insert k newV t
mergeValues (Wildcard x) _ = Wildcard x
mergeValues _ (Wildcard x) = Wildcard x
mergeValues RVar RVar = RVar
mergeValues RVar (RouteMatch x y) = RouteMatchOrVar x y
mergeValues (RouteMatch _ _) (RouteMatch x y) = RouteMatch x y
mergeValues (RouteMatch x y) RVar = RouteMatchOrVar x y
mergeValues (RouteMatchOrVar _ _) (RouteMatch x y) = RouteMatchOrVar x y
mergeValues (RouteMatchOrVar x y) _ = RouteMatchOrVar x y
mergeValues _ v = v
-- | @a '</>' b@ separates the path components @a@ and @b@ with a slash.
-- This is actually just a synonym for 'mappend'.
(</>) :: Route -> Route -> Route
(</>) = (<>)
-- | Represents the root resource (@/@). This should usually be the first path declared in a 'RoutingSpec'.
root :: Route
root = Route []
-- | Captures a named in a route and adds it to the 'routingParams' hashmap under the provided 'Text' value. For example,
--
-- @
-- "blog" '</>' 'var' "date" '</>' 'var' "post"
-- @
--
-- will capture all URLs of the form @\/blog\/$date\/$post@, and add @date@ and @post@ to the 'routingParams'
-- contained within the resource this route maps to.
var :: Text -> Route
var t = Route [Var t]
-- | Captures a wildcard route. For example,
--
-- @
-- "emcees" '</>' star
-- @
--
-- will match @\/emcees@, @\/emcees/biggie@, @\/emcees\/earl\/vince@, and so on and so forth.
star :: Route
star = Route [RestUnbound]
-- Routing trie creation algorithm
-- 1. Store full paths as keys up to first `var`
-- 2. Calculate Base64 encoding of the URL portion preceding the
-- `var` ++ "var" and use that as key for the next part of the
-- route spec.
-- 3. Repeat step 2 for every `var` encountered until the route
-- is completed and maps to a resource.
(#>) :: MonadWriter [(B.ByteString, (RouteLeaf a))] m
=> Route -> Resource a -> m ()
k #> v = do
let (key, routes, vars, isWild) = foldl routeFoldFun ("", [], [], False) (getRoute k)
key' = if BC8.null key then "/"
else key
ctor = if isWild
then Wildcard (RoutedResource k v)
else RouteMatch (RoutedResource k v) vars
tell $ (key', ctor) : routes
where
routeFoldFun (kps, rt, vs, False) (Bound x) =
(B.concat [kps, "/", encodeUtf8 x], rt, vs, False)
routeFoldFun (kps, rt, vs, False) (Var x) =
let partKey = Base64.encode $ B.concat [kps, "var"]
rt' = (kps, RVar) : rt
in (partKey, rt', x:vs, False)
routeFoldFun (kps, rt, vs, False) RestUnbound =
(kps, rt, vs, True)
routeFoldFun (kps, rt, vs, True) _ =
(kps, rt, vs, True)
(#>=) :: MonadWriter [(B.ByteString, (RouteLeaf a))] m
=> Route -> m (Resource a) -> m ()
k #>= mv = mv >>= (k #>)
-- | Represents a fully-specified set of routes that map paths (represented as 'Route's) to 'Resource's. 'RoutingSpec's are declared with do-notation, to wit:
--
-- @
-- myRoutes :: RoutingSpec IO ()
-- myRoutes = do
-- root #> myRootResource
-- "blog" '</>' var "date" '</>' var "post" #> blogPostResource
-- "about" #> aboutResource
-- "anything" '</>' star #> wildcardResource
-- @
--
newtype RoutingSpec m a = RoutingSpec {
getRouter :: Writer [(B.ByteString, RouteLeaf m)] a
} deriving ( Functor, Applicative, Monad
, MonadWriter [(B.ByteString, RouteLeaf m)]
)
route :: Trie (RouteLeaf a)
-> BC8.ByteString
-> Maybe (RoutedResource a, (HashMap Text Text, [Text]))
route routes pInfo = let matchRes = Trie.match routes pInfo
in matchRoute' routes matchRes mempty Nothing
matchRoute' :: Trie (RouteLeaf a)
-> Maybe (B.ByteString, RouteLeaf a, B.ByteString)
-> [Text]
-> Maybe B.ByteString
-> Maybe (RoutedResource a, (HashMap Text Text, [Text]))
matchRoute' _routes Nothing _ps _dsp =
-- Nothing even partially matched the route
Nothing
matchRoute' routes (Just (matched, RouteMatchOrVar r vars, "")) ps dsp =
-- The matched key is also a prefix of other routes, but the
-- entire path matched so handle like a RouteMatch.
matchRoute' routes (Just (matched, RouteMatch r vars, "")) ps dsp
matchRoute' _routes (Just (matched, RouteMatch r vars, "")) ps dsp =
-- The entire path matched so return the resource, params, and
-- dispatch path
Just (r, (fromList $ zip vars ps, dispatchList dsp matched))
where
dispatchList (Just d) m = toTextList $ B.concat [d, m]
dispatchList Nothing _ = mempty
toTextList bs = decodeUtf8 <$> BC8.split '/' bs
matchRoute' _routes (Just (_matched, RouteMatch _r _vars, _)) _ps _dsp =
-- Part of the request path matched, but the trie value at the
-- matched prefix is not an RVar or RouteMatchOrVar so there is no
-- match.
Nothing
matchRoute' routes (Just (matched, RouteMatchOrVar _r _vars, rest)) ps dsp =
-- Part of the request path matched and the trie value at the
-- matched prefix is a RouteMatchOrVar so handle it the same as if
-- the value were RVar.
matchRoute' routes (Just (matched, RVar, rest)) ps dsp
matchRoute' routes (Just (matched, RVar, rest)) ps dsp
| BC8.null rest = Nothing
| BC8.take 2 rest == "//" = Nothing
| BC8.head rest == '/' =
-- Part of the request path matched and the trie value at the
-- matched prefix is a RVar so calculate the key for the next part
-- of the route and continue attempting to match.
let nextKey = B.concat [ Base64.encode $ B.concat [matched, "var"]
, BC8.dropWhile (/='/') $ BC8.dropWhile (=='/') rest
]
updDsp = if isNothing dsp then Just mempty
else dsp
paramVal = decodeUtf8 . BC8.takeWhile (/='/')
$ BC8.dropWhile (=='/') rest
matchRes = Trie.match routes nextKey
in matchRoute' routes matchRes (paramVal:ps) updDsp
| otherwise = Nothing
matchRoute' _routes (Just (_matched, Wildcard r, rest)) _ps _dsp =
-- Encountered a wildcard (star) value in the trie so it's a match
Just (r, (mempty, decodeUtf8 <$> [BC8.dropWhile (=='/') rest]))