webgear-core-1.3.0: src/WebGear/Core/Trait/Path.hs
{- | Middlewares related to request paths.
All the middlewares below attempt to match components of the request
path. In case of a mismatch, they abandon the current handler and
tries the next handler.
-}
module WebGear.Core.Trait.Path (
Path (..),
PathVar (..),
PathVarError (..),
PathEnd (..),
path,
pathVar,
pathEnd,
match,
route,
) where
import Control.Arrow (ArrowChoice (..), (>>>))
import Control.Arrow.Operations (ArrowError)
import Data.Function ((&))
import Data.Kind (Type)
import qualified Data.List as List
import Data.List.NonEmpty (NonEmpty (..), filter, toList)
import Data.Text (Text)
import GHC.TypeLits (Symbol)
import Language.Haskell.TH (appE, conE)
import Language.Haskell.TH.Quote (QuasiQuoter (..))
import Language.Haskell.TH.Syntax (Exp (..), Lit (..), Q, TyLit (StrTyLit), Type (..), mkName)
import WebGear.Core.Handler (Middleware, RouteMismatch, routeMismatch)
import WebGear.Core.Request (Request)
import WebGear.Core.Trait (Absence, Attribute, Get, Prerequisite, probe)
import WebGear.Core.Trait.Method (method)
import Prelude hiding (drop, filter, take)
{- | A path component which is literally matched against the request
but discarded after that.
-}
newtype Path = Path Text
type instance Attribute Path Request = ()
type instance Absence Path = ()
type instance Prerequisite Path ts = ()
{- | A path variable that is extracted and converted to a value of
type @val@. The @tag@ is usually a type-level symbol (string) to
uniquely identify this variable.
-}
data PathVar (tag :: Symbol) (val :: Data.Kind.Type) = PathVar
-- | Failure to extract a 'PathVar'
data PathVarError = PathVarNotFound | PathVarParseError Text
deriving stock (Eq, Show, Read)
type instance Attribute (PathVar tag val) Request = val
type instance Absence (PathVar tag val) = PathVarError
type instance Prerequisite (PathVar tag val) ts = ()
-- | Trait to indicate that no more path components are present in the request
data PathEnd = PathEnd
type instance Attribute PathEnd Request = ()
type instance Absence PathEnd = ()
type instance Prerequisite PathEnd ts = ()
{- | A middleware that literally matches path @s@.
The symbol @s@ could contain one or more parts separated by a
forward slash character. The route will be rejected if there is no
match.
For example, the following code could be used to match the URL path
\"a\/b\/c\" and then invoke @handler@:
> path "a/b/c" handler
-}
path ::
(Get h Path, ArrowChoice h, ArrowError RouteMismatch h) =>
Text ->
Middleware h ts (Path : ts)
path s nextHandler = probe (Path s) >>> routeMismatch ||| nextHandler
{-# INLINE path #-}
{- | A middleware that captures a path variable from a single path
component.
The value captured is converted to a value of type @val@. The route
will be rejected if the value is not found or cannot be converted.
For example, the following code could be used to read a path
component as 'Int' tagged with the symbol \"objId\", and then
invoke @handler@:
> pathVar @"objId" @Int handler
-}
pathVar ::
forall tag val h ts.
(Get h (PathVar tag val), ArrowChoice h, ArrowError RouteMismatch h) =>
Middleware h ts (PathVar tag val : ts)
pathVar nextHandler = probe PathVar >>> routeMismatch ||| nextHandler
{-# INLINE pathVar #-}
-- | A middleware that verifies that end of path is reached.
pathEnd ::
(Get h PathEnd, ArrowChoice h, ArrowError RouteMismatch h) =>
Middleware h ts (PathEnd : ts)
pathEnd nextHandler = probe PathEnd >>> routeMismatch ||| nextHandler
{-# INLINE pathEnd #-}
{- | Produces middleware(s) to match an optional HTTP method and some
path components.
This middleware matches a prefix of path components, the remaining
components can be matched by subsequent uses of 'match'.
This quasiquoter can be used in several ways:
+---------------------------------------+---------------------------------------------------------------------------------+
| QuasiQuoter | Equivalent Middleware |
+=======================================+=================================================================================+
| @[match| \/a\/b\/c |]@ | @'path' \"\/a\/b\/c\"@ |
+---------------------------------------+---------------------------------------------------------------------------------+
| @[match| \/a\/b\/objId:Int\/d |]@ | @'path' \"\/a\/b\" . 'pathVar' \@\"objId\" \@Int . 'path' \"d\"@ |
+---------------------------------------+---------------------------------------------------------------------------------+
| @[match| GET \/a\/b\/c |]@ | @'method' GET . 'path' \"\/a\/b\/c\"@ |
+---------------------------------------+---------------------------------------------------------------------------------+
| @[match| GET \/a\/b\/objId:Int\/d |]@ | @'method' GET . 'path' \"\/a\/b\" . 'pathVar' \@\"objId\" \@Int . 'path' \"d\"@ |
+---------------------------------------+---------------------------------------------------------------------------------+
-}
match :: QuasiQuoter
match =
QuasiQuoter
{ quoteExp = toMatchExp
, quotePat = const $ fail "match cannot be used in a pattern"
, quoteType = const $ fail "match cannot be used in a type"
, quoteDec = const $ fail "match cannot be used in a declaration"
}
{- | Produces middleware(s) to match an optional HTTP method and the
entire request path.
This middleware is intended to be used in cases where the entire
path needs to be matched. Use 'match' middleware to match only an
initial portion of the path.
This quasiquoter can be used in several ways:
+---------------------------------------+----------------------------------------------------------------------------------------------+
| QuasiQuoter | Equivalent Middleware |
+=======================================+==============================================================================================+
| @[route| \/a\/b\/c |]@ | @'path' \"\/a\/b\/c\" . 'pathEnd'@ |
+---------------------------------------+----------------------------------------------------------------------------------------------+
| @[route| \/a\/b\/objId:Int\/d |]@ | @'path' \"\/a\/b\" . 'pathVar' \@\"objId\" \@Int . 'path' \"d\" . 'pathEnd'@ |
+---------------------------------------+----------------------------------------------------------------------------------------------+
| @[route| GET \/a\/b\/c |]@ | @'method' GET . 'path' \"\/a\/b\/c\" . 'pathEnd'@ |
+---------------------------------------+----------------------------------------------------------------------------------------------+
| @[route| GET \/a\/b\/objId:Int\/d |]@ | @'method' GET . 'path' \"\/a\/b\" . 'pathVar' \@\"objId\" \@Int . 'path' \"d\" . 'pathEnd')@ |
+---------------------------------------+----------------------------------------------------------------------------------------------+
-}
route :: QuasiQuoter
route =
QuasiQuoter
{ quoteExp = toRouteExp
, quotePat = const $ fail "route cannot be used in a pattern"
, quoteType = const $ fail "route cannot be used in a type"
, quoteDec = const $ fail "route cannot be used in a declaration"
}
toRouteExp :: String -> Q Exp
toRouteExp s = do
e <- toMatchExp s
pure $ compose e (VarE 'pathEnd)
toMatchExp :: String -> Q Exp
toMatchExp s = case List.words s of
[m, p] -> toMethodAndPathExps m p
[p] -> do
pathExps <- toPathExps p
pure $ List.foldr1 compose pathExps
_ -> fail "Expected an HTTP method and a path or just a path"
where
toMethodAndPathExps :: String -> String -> Q Exp
toMethodAndPathExps m p = do
methodExp <- [|method|] `appE` conE (mkName m)
pathExps <- toPathExps p
pure $ List.foldr1 compose $ methodExp :| pathExps
toPathExps :: String -> Q [Exp]
toPathExps p =
splitOn '/' p
& filter (/= "")
& fmap (splitOn ':')
& List.foldr joinPath []
& mapM toPathExp
joinPath :: NonEmpty String -> [NonEmpty String] -> [NonEmpty String]
joinPath p [] = [p]
joinPath (p :| []) ((p' :| []) : xs) = ((p <> "/" <> p') :| []) : xs
joinPath y (x : xs) = y : x : xs
toPathExp :: NonEmpty String -> Q Exp
toPathExp (p :| []) = pure $ AppE (VarE 'path) (LitE $ StringL p)
toPathExp (v :| [t]) = pure $ AppTypeE (AppTypeE (VarE 'pathVar) (LitT $ StrTyLit v)) (ConT $ mkName t)
toPathExp xs = fail $ "Invalid path component: " <> List.intercalate ":" (toList xs)
compose :: Exp -> Exp -> Exp
compose l = UInfixE l (VarE $ mkName ".")
splitOn :: (Eq a) => a -> [a] -> NonEmpty [a]
splitOn sep = foldr f ([] :| [])
where
f x acc | x == sep = [] :| toList acc
f x (y :| ys) = (x : y) :| ys