-- | -- 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 ".")