webgear-server-0.2.0: src/WebGear/Middlewares/Path.hs
-- |
-- Copyright : (c) Raghu Kaippully, 2020
-- License : MPL-2.0
-- Maintainer : rkaippully@gmail.com
--
-- Middlewares related to route paths.
module WebGear.Middlewares.Path
( Path
, PathVar
, PathVarError (..)
, PathEnd
, path
, pathVar
, pathEnd
, match
, route
) where
import Control.Arrow (Kleisli (..))
import Control.Monad ((>=>))
import Control.Monad.State.Strict (MonadState (..))
import Data.Foldable (toList)
import Data.Function ((&))
import Data.List.NonEmpty (NonEmpty (..), filter)
import Data.Proxy (Proxy (..))
import Data.Text (Text, pack)
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
import Language.Haskell.TH.Quote (QuasiQuoter (..))
import Language.Haskell.TH.Syntax (Exp (..), Q, TyLit (..), Type (..), mkName)
import Prelude hiding (drop, filter, take)
import Web.HttpApiData (FromHttpApiData (..))
import WebGear.Middlewares.Method (method)
import WebGear.Trait (Result (..), Trait (..), probe)
import WebGear.Types (MonadRouter (..), PathInfo (..), Request, RequestMiddleware')
import WebGear.Util (splitOn)
import qualified Data.List as List
-- | A path component which is literally matched against the request
-- but discarded after that.
data Path (s :: Symbol)
instance (KnownSymbol s, MonadState PathInfo m) => Trait (Path s) Request m where
type Attribute (Path s) Request = ()
type Absence (Path s) Request = ()
toAttribute :: Request -> m (Result (Path s) Request)
toAttribute _ = do
PathInfo actualPath <- get
case List.stripPrefix expectedPath actualPath of
Nothing -> pure $ NotFound ()
Just rest -> do
put $ PathInfo rest
pure $ Found ()
where
expectedPath = Proxy @s
& symbolVal
& splitOn '/'
& filter (/= "")
& map pack
-- | 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 val
-- | Failure to extract a 'PathVar'
data PathVarError = PathVarNotFound | PathVarParseError Text
deriving (Eq, Show, Read)
instance (FromHttpApiData val, MonadState PathInfo m) => Trait (PathVar tag val) Request m where
type Attribute (PathVar tag val) Request = val
type Absence (PathVar tag val) Request = PathVarError
toAttribute :: Request -> m (Result (PathVar tag val) Request)
toAttribute _ = do
PathInfo actualPath <- get
case actualPath of
[] -> pure $ NotFound PathVarNotFound
(x:xs) -> case parseUrlPiece @val x of
Left e -> pure $ NotFound $ PathVarParseError e
Right v -> do
put $ PathInfo xs
pure $ Found v
-- | Trait to indicate that no more path components are present in the request
data PathEnd
instance MonadState PathInfo m => Trait PathEnd Request m where
type Attribute PathEnd Request = ()
type Absence PathEnd Request = ()
toAttribute :: Request -> m (Result PathEnd Request)
toAttribute _ = do
PathInfo actualPath <- get
pure $ if null actualPath
then Found ()
else NotFound ()
-- | 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 :: forall s ts m a. (KnownSymbol s, MonadRouter m)
=> RequestMiddleware' m ts (Path s:ts) a
path handler = Kleisli $
probe @(Path s) >=> either (const rejectRoute) (runKleisli handler)
-- | A middleware that captures a path variable from a single path
-- component.
--
-- The value captured is converted to a value of type @val@ via
-- 'FromHttpApiData'. 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 ts m a. (FromHttpApiData val, MonadRouter m)
=> RequestMiddleware' m ts (PathVar tag val:ts) a
pathVar handler = Kleisli $
probe @(PathVar tag val) >=> either (const rejectRoute) (runKleisli handler)
-- | A middleware that verifies that end of path is reached.
pathEnd :: MonadRouter m => RequestMiddleware' m ts (PathEnd:ts) a
pathEnd handler = Kleisli $
probe @PathEnd >=> either (const rejectRoute) (runKleisli handler)
-- | 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] -> do
let methodExp = AppTypeE (VarE 'method) (ConT $ mkName m)
pathExps <- toPathExps p
pure $ List.foldr1 compose $ methodExp :| pathExps
[p] -> do
pathExps <- toPathExps p
pure $ List.foldr1 compose pathExps
_ -> fail "Expected an HTTP method and a path or just a path"
where
toPathExps :: String -> Q [Exp]
toPathExps p = splitOn '/' p
& filter (/= "")
& fmap (splitOn ':')
& List.foldr joinPath []
& fmap toPathExp
& sequence
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 $ AppTypeE (VarE 'path) (LitT $ StrTyLit 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 ".")