Merge 801f075d7d
into 8f081bd9ad
This commit is contained in:
commit
31ce3e4423
33
changelog.d/1561
Normal file
33
changelog.d/1561
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
synopsis: New combinator to return routed path in response headers
|
||||||
|
prs: #1561
|
||||||
|
issues: #1553
|
||||||
|
|
||||||
|
description: {
|
||||||
|
|
||||||
|
This commit introduces a new type-level combinator, `WithRoutingHeader`.
|
||||||
|
It modifies the behaviour of the following sub-API, such that all endpoints of said API return an additional routing header in their response.
|
||||||
|
|
||||||
|
A routing header is a header that specifies which endpoint the incoming request was routed to.
|
||||||
|
Endpoint are designated by their path, in which `Capture'` and `CaptureAll` combinators are replaced by a capture hint.
|
||||||
|
|
||||||
|
This header can be used by downstream middlewares to gather information about individual endpoints, since in most cases
|
||||||
|
a routing header uniquely identifies a single endpoint.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
type MyApi =
|
||||||
|
WithRoutingHeader :> "by-id" :> Capture "id" Int :> Get '[JSON] Foo
|
||||||
|
-- GET /by-id/1234 will return a response with the following header:
|
||||||
|
-- ("Servant-Routed-Path", "/by-id/<id::Int>")
|
||||||
|
```
|
||||||
|
|
||||||
|
To achieve this, two refactorings were necessary:
|
||||||
|
|
||||||
|
* Introduce a type `RouterEnv env` to encapsulate the `env` type (as in `Router env a`), which contains a tuple-encoded list of url pieces parsed from the incoming request. This type makes it possible to pass more information throughout the routing process, and the computation of the `Delayed env c` associated with each request.
|
||||||
|
* Introduce a new kind of router, which only modifies the `RouterEnv`, and doesn't affect the routing process otherwise: `EnvRouter (RouterEnv env -> RouterEnv env) (Router' env a)`.
|
||||||
|
This new router is used when encountering the `WithRoutingHeader` combinator in an API, to notify the endpoints of the sub-API that they must produce a routing header (this behaviour is disabled by default).
|
||||||
|
|
||||||
|
This PR also introduces `Spec` tests for the `WithRoutingHeader` combinator, which showcase some of its possible uses.
|
||||||
|
|
||||||
|
}
|
|
@ -46,6 +46,7 @@ library
|
||||||
Servant.Server.Internal.DelayedIO
|
Servant.Server.Internal.DelayedIO
|
||||||
Servant.Server.Internal.ErrorFormatter
|
Servant.Server.Internal.ErrorFormatter
|
||||||
Servant.Server.Internal.Handler
|
Servant.Server.Internal.Handler
|
||||||
|
Servant.Server.Internal.RouterEnv
|
||||||
Servant.Server.Internal.RouteResult
|
Servant.Server.Internal.RouteResult
|
||||||
Servant.Server.Internal.Router
|
Servant.Server.Internal.Router
|
||||||
Servant.Server.Internal.RoutingApplication
|
Servant.Server.Internal.RoutingApplication
|
||||||
|
@ -142,6 +143,7 @@ test-suite spec
|
||||||
, base-compat
|
, base-compat
|
||||||
, base64-bytestring
|
, base64-bytestring
|
||||||
, bytestring
|
, bytestring
|
||||||
|
, containers
|
||||||
, http-types
|
, http-types
|
||||||
, mtl
|
, mtl
|
||||||
, resourcet
|
, resourcet
|
||||||
|
|
|
@ -27,6 +27,7 @@ module Servant.Server.Internal
|
||||||
, module Servant.Server.Internal.ErrorFormatter
|
, module Servant.Server.Internal.ErrorFormatter
|
||||||
, module Servant.Server.Internal.Handler
|
, module Servant.Server.Internal.Handler
|
||||||
, module Servant.Server.Internal.Router
|
, module Servant.Server.Internal.Router
|
||||||
|
, module Servant.Server.Internal.RouterEnv
|
||||||
, module Servant.Server.Internal.RouteResult
|
, module Servant.Server.Internal.RouteResult
|
||||||
, module Servant.Server.Internal.RoutingApplication
|
, module Servant.Server.Internal.RoutingApplication
|
||||||
, module Servant.Server.Internal.ServerError
|
, module Servant.Server.Internal.ServerError
|
||||||
|
@ -77,7 +78,7 @@ import Servant.API
|
||||||
QueryParam', QueryParams, Raw, ReflectMethod (reflectMethod),
|
QueryParam', QueryParams, Raw, ReflectMethod (reflectMethod),
|
||||||
RemoteHost, ReqBody', SBool (..), SBoolI (..), SourceIO,
|
RemoteHost, ReqBody', SBool (..), SBoolI (..), SourceIO,
|
||||||
Stream, StreamBody', Summary, ToSourceIO (..), Vault, Verb,
|
Stream, StreamBody', Summary, ToSourceIO (..), Vault, Verb,
|
||||||
WithNamedContext, NamedRoutes)
|
WithNamedContext, WithRoutingHeader, NamedRoutes)
|
||||||
import Servant.API.Generic (GenericMode(..), ToServant, ToServantApi, GServantProduct, toServant, fromServant)
|
import Servant.API.Generic (GenericMode(..), ToServant, ToServantApi, GServantProduct, toServant, fromServant)
|
||||||
import Servant.API.ContentTypes
|
import Servant.API.ContentTypes
|
||||||
(AcceptHeader (..), AllCTRender (..), AllCTUnrender (..),
|
(AcceptHeader (..), AllCTRender (..), AllCTUnrender (..),
|
||||||
|
@ -106,6 +107,7 @@ import Servant.Server.Internal.ErrorFormatter
|
||||||
import Servant.Server.Internal.Handler
|
import Servant.Server.Internal.Handler
|
||||||
import Servant.Server.Internal.Router
|
import Servant.Server.Internal.Router
|
||||||
import Servant.Server.Internal.RouteResult
|
import Servant.Server.Internal.RouteResult
|
||||||
|
import Servant.Server.Internal.RouterEnv
|
||||||
import Servant.Server.Internal.RoutingApplication
|
import Servant.Server.Internal.RoutingApplication
|
||||||
import Servant.Server.Internal.ServerError
|
import Servant.Server.Internal.ServerError
|
||||||
|
|
||||||
|
@ -244,6 +246,23 @@ instance (KnownSymbol capture, FromHttpApiData a, Typeable a
|
||||||
formatError = urlParseErrorFormatter $ getContextEntry (mkContextWithErrorFormatter context)
|
formatError = urlParseErrorFormatter $ getContextEntry (mkContextWithErrorFormatter context)
|
||||||
hint = CaptureHint (T.pack $ symbolVal $ Proxy @capture) (typeRep (Proxy :: Proxy [a]))
|
hint = CaptureHint (T.pack $ symbolVal $ Proxy @capture) (typeRep (Proxy :: Proxy [a]))
|
||||||
|
|
||||||
|
-- | Using 'WithRoutingHeaders' in one of the endpoints for your API,
|
||||||
|
-- will automatically add routing headers to the response generated by the server.
|
||||||
|
--
|
||||||
|
-- @since 0.20
|
||||||
|
--
|
||||||
|
instance ( HasServer api context
|
||||||
|
, HasContextEntry (MkContextWithErrorFormatter context) ErrorFormatters
|
||||||
|
)
|
||||||
|
=> HasServer (WithRoutingHeader :> api) context where
|
||||||
|
|
||||||
|
type ServerT (WithRoutingHeader :> api) m = ServerT api m
|
||||||
|
|
||||||
|
hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt s
|
||||||
|
|
||||||
|
route _ context d =
|
||||||
|
EnvRouter enableRoutingHeaders $ route (Proxy :: Proxy api) context d
|
||||||
|
|
||||||
allowedMethodHead :: Method -> Request -> Bool
|
allowedMethodHead :: Method -> Request -> Bool
|
||||||
allowedMethodHead method request = method == methodGet && requestMethod request == methodHead
|
allowedMethodHead method request = method == methodGet && requestMethod request == methodHead
|
||||||
|
|
||||||
|
@ -295,7 +314,10 @@ noContentRouter method status action = leafRouter route'
|
||||||
route' env request respond =
|
route' env request respond =
|
||||||
runAction (action `addMethodCheck` methodCheck method request)
|
runAction (action `addMethodCheck` methodCheck method request)
|
||||||
env request respond $ \ _output ->
|
env request respond $ \ _output ->
|
||||||
Route $ responseLBS status [] ""
|
let headers = if (shouldReturnRoutedPath env)
|
||||||
|
then [(hRoutedPathHeader, cs $ routedPathRepr env)]
|
||||||
|
else []
|
||||||
|
in Route $ responseLBS status headers ""
|
||||||
|
|
||||||
instance {-# OVERLAPPABLE #-}
|
instance {-# OVERLAPPABLE #-}
|
||||||
( AllCTRender ctypes a, ReflectMethod method, KnownNat status
|
( AllCTRender ctypes a, ReflectMethod method, KnownNat status
|
||||||
|
|
|
@ -14,11 +14,15 @@ import Control.Monad.Reader
|
||||||
(ask)
|
(ask)
|
||||||
import Control.Monad.Trans.Resource
|
import Control.Monad.Trans.Resource
|
||||||
(ResourceT, runResourceT)
|
(ResourceT, runResourceT)
|
||||||
|
import Data.String.Conversions
|
||||||
|
(cs)
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
(Request, Response)
|
(Request, Response, mapResponseHeaders)
|
||||||
|
|
||||||
import Servant.Server.Internal.DelayedIO
|
import Servant.Server.Internal.DelayedIO
|
||||||
import Servant.Server.Internal.Handler
|
import Servant.Server.Internal.Handler
|
||||||
|
import Servant.Server.Internal.RouterEnv
|
||||||
|
(RouterEnv (..), hRoutedPathHeader, routedPathRepr)
|
||||||
import Servant.Server.Internal.RouteResult
|
import Servant.Server.Internal.RouteResult
|
||||||
import Servant.Server.Internal.ServerError
|
import Servant.Server.Internal.ServerError
|
||||||
|
|
||||||
|
@ -228,12 +232,12 @@ passToServer Delayed{..} x =
|
||||||
-- This should only be called once per request; otherwise the guarantees about
|
-- This should only be called once per request; otherwise the guarantees about
|
||||||
-- effect and HTTP error ordering break down.
|
-- effect and HTTP error ordering break down.
|
||||||
runDelayed :: Delayed env a
|
runDelayed :: Delayed env a
|
||||||
-> env
|
-> RouterEnv env
|
||||||
-> Request
|
-> Request
|
||||||
-> ResourceT IO (RouteResult a)
|
-> ResourceT IO (RouteResult a)
|
||||||
runDelayed Delayed{..} env = runDelayedIO $ do
|
runDelayed Delayed{..} env = runDelayedIO $ do
|
||||||
r <- ask
|
r <- ask
|
||||||
c <- capturesD env
|
c <- capturesD $ routerEnv env
|
||||||
methodD
|
methodD
|
||||||
a <- authD
|
a <- authD
|
||||||
acceptD
|
acceptD
|
||||||
|
@ -248,7 +252,7 @@ runDelayed Delayed{..} env = runDelayedIO $ do
|
||||||
-- Also takes a continuation for how to turn the
|
-- Also takes a continuation for how to turn the
|
||||||
-- result of the delayed server into a response.
|
-- result of the delayed server into a response.
|
||||||
runAction :: Delayed env (Handler a)
|
runAction :: Delayed env (Handler a)
|
||||||
-> env
|
-> RouterEnv env
|
||||||
-> Request
|
-> Request
|
||||||
-> (RouteResult Response -> IO r)
|
-> (RouteResult Response -> IO r)
|
||||||
-> (a -> RouteResult Response)
|
-> (a -> RouteResult Response)
|
||||||
|
@ -261,8 +265,12 @@ runAction action env req respond k = runResourceT $
|
||||||
go (Route a) = liftIO $ do
|
go (Route a) = liftIO $ do
|
||||||
e <- runHandler a
|
e <- runHandler a
|
||||||
case e of
|
case e of
|
||||||
Left err -> return . Route $ responseServerError err
|
Left err -> return . Route . withRoutingHeaders $ responseServerError err
|
||||||
Right x -> return $! k x
|
Right x -> return $! withRoutingHeaders <$> k x
|
||||||
|
withRoutingHeaders :: Response -> Response
|
||||||
|
withRoutingHeaders = if shouldReturnRoutedPath env
|
||||||
|
then mapResponseHeaders ((hRoutedPathHeader, cs $ routedPathRepr env) :)
|
||||||
|
else id
|
||||||
|
|
||||||
{- Note [Existential Record Update]
|
{- Note [Existential Record Update]
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
{-# LANGUAGE DeriveFunctor #-}
|
{-# LANGUAGE DeriveFunctor #-}
|
||||||
{-# LANGUAGE GADTs #-}
|
{-# LANGUAGE GADTs #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE TupleSections #-}
|
||||||
module Servant.Server.Internal.Router where
|
module Servant.Server.Internal.Router where
|
||||||
|
|
||||||
import Prelude ()
|
import Prelude ()
|
||||||
|
@ -17,29 +18,16 @@ import qualified Data.Map as M
|
||||||
import Data.Text
|
import Data.Text
|
||||||
(Text)
|
(Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Typeable
|
|
||||||
(TypeRep)
|
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
(Response, pathInfo)
|
(Response, pathInfo)
|
||||||
import Servant.Server.Internal.ErrorFormatter
|
import Servant.Server.Internal.ErrorFormatter
|
||||||
|
import Servant.Server.Internal.RouterEnv
|
||||||
import Servant.Server.Internal.RouteResult
|
import Servant.Server.Internal.RouteResult
|
||||||
import Servant.Server.Internal.RoutingApplication
|
import Servant.Server.Internal.RoutingApplication
|
||||||
import Servant.Server.Internal.ServerError
|
import Servant.Server.Internal.ServerError
|
||||||
|
|
||||||
type Router env = Router' env RoutingApplication
|
type Router env = Router' env RoutingApplication
|
||||||
|
|
||||||
data CaptureHint = CaptureHint
|
|
||||||
{ captureName :: Text
|
|
||||||
, captureType :: TypeRep
|
|
||||||
}
|
|
||||||
deriving (Show, Eq)
|
|
||||||
|
|
||||||
toCaptureTag :: CaptureHint -> Text
|
|
||||||
toCaptureTag hint = captureName hint <> "::" <> (T.pack . show) (captureType hint)
|
|
||||||
|
|
||||||
toCaptureTags :: [CaptureHint] -> Text
|
|
||||||
toCaptureTags hints = "<" <> T.intercalate "|" (map toCaptureTag hints) <> ">"
|
|
||||||
|
|
||||||
-- | Internal representation of a router.
|
-- | Internal representation of a router.
|
||||||
--
|
--
|
||||||
-- The first argument describes an environment type that is
|
-- The first argument describes an environment type that is
|
||||||
|
@ -48,7 +36,7 @@ toCaptureTags hints = "<" <> T.intercalate "|" (map toCaptureTag hints) <> ">"
|
||||||
-- components that can be used to process captures.
|
-- components that can be used to process captures.
|
||||||
--
|
--
|
||||||
data Router' env a =
|
data Router' env a =
|
||||||
StaticRouter (Map Text (Router' env a)) [env -> a]
|
StaticRouter (Map Text (Router' env a)) [RouterEnv env -> a]
|
||||||
-- ^ the map contains routers for subpaths (first path component used
|
-- ^ the map contains routers for subpaths (first path component used
|
||||||
-- for lookup and removed afterwards), the list contains handlers
|
-- for lookup and removed afterwards), the list contains handlers
|
||||||
-- for the empty path, to be tried in order
|
-- for the empty path, to be tried in order
|
||||||
|
@ -58,10 +46,13 @@ data Router' env a =
|
||||||
| CaptureAllRouter [CaptureHint] (Router' ([Text], env) a)
|
| CaptureAllRouter [CaptureHint] (Router' ([Text], env) a)
|
||||||
-- ^ all path components are passed to the child router in its
|
-- ^ all path components are passed to the child router in its
|
||||||
-- environment and are removed afterwards
|
-- environment and are removed afterwards
|
||||||
| RawRouter (env -> a)
|
| RawRouter (RouterEnv env -> a)
|
||||||
-- ^ to be used for routes we do not know anything about
|
-- ^ to be used for routes we do not know anything about
|
||||||
| Choice (Router' env a) (Router' env a)
|
| Choice (Router' env a) (Router' env a)
|
||||||
-- ^ left-biased choice between two routers
|
-- ^ left-biased choice between two routers
|
||||||
|
| EnvRouter (RouterEnv env -> RouterEnv env) (Router' env a)
|
||||||
|
-- ^ modifies the environment, and passes it to the child router
|
||||||
|
-- @since 0.20
|
||||||
deriving Functor
|
deriving Functor
|
||||||
|
|
||||||
-- | Smart constructor for a single static path component.
|
-- | Smart constructor for a single static path component.
|
||||||
|
@ -71,7 +62,7 @@ pathRouter t r = StaticRouter (M.singleton t r) []
|
||||||
-- | Smart constructor for a leaf, i.e., a router that expects
|
-- | Smart constructor for a leaf, i.e., a router that expects
|
||||||
-- the empty path.
|
-- the empty path.
|
||||||
--
|
--
|
||||||
leafRouter :: (env -> a) -> Router' env a
|
leafRouter :: (RouterEnv env -> a) -> Router' env a
|
||||||
leafRouter l = StaticRouter M.empty [l]
|
leafRouter l = StaticRouter M.empty [l]
|
||||||
|
|
||||||
-- | Smart constructor for the choice between routers.
|
-- | Smart constructor for the choice between routers.
|
||||||
|
@ -126,6 +117,7 @@ routerStructure (Choice r1 r2) =
|
||||||
ChoiceStructure
|
ChoiceStructure
|
||||||
(routerStructure r1)
|
(routerStructure r1)
|
||||||
(routerStructure r2)
|
(routerStructure r2)
|
||||||
|
routerStructure (EnvRouter _ r) = routerStructure r
|
||||||
|
|
||||||
-- | Compare the structure of two routers.
|
-- | Compare the structure of two routers.
|
||||||
--
|
--
|
||||||
|
@ -172,9 +164,9 @@ tweakResponse f = fmap (\a -> \req cont -> a req (cont . f))
|
||||||
|
|
||||||
-- | Interpret a router as an application.
|
-- | Interpret a router as an application.
|
||||||
runRouter :: NotFoundErrorFormatter -> Router () -> RoutingApplication
|
runRouter :: NotFoundErrorFormatter -> Router () -> RoutingApplication
|
||||||
runRouter fmt r = runRouterEnv fmt r ()
|
runRouter fmt r = runRouterEnv fmt r $ emptyEnv ()
|
||||||
|
|
||||||
runRouterEnv :: NotFoundErrorFormatter -> Router env -> env -> RoutingApplication
|
runRouterEnv :: NotFoundErrorFormatter -> Router env -> RouterEnv env -> RoutingApplication
|
||||||
runRouterEnv fmt router env request respond =
|
runRouterEnv fmt router env request respond =
|
||||||
case router of
|
case router of
|
||||||
StaticRouter table ls ->
|
StaticRouter table ls ->
|
||||||
|
@ -184,24 +176,31 @@ runRouterEnv fmt router env request respond =
|
||||||
[""] -> runChoice fmt ls env request respond
|
[""] -> runChoice fmt ls env request respond
|
||||||
first : rest | Just router' <- M.lookup first table
|
first : rest | Just router' <- M.lookup first table
|
||||||
-> let request' = request { pathInfo = rest }
|
-> let request' = request { pathInfo = rest }
|
||||||
in runRouterEnv fmt router' env request' respond
|
newEnv = appendPathPiece (StaticPiece first) env
|
||||||
|
in runRouterEnv fmt router' newEnv request' respond
|
||||||
_ -> respond $ Fail $ fmt request
|
_ -> respond $ Fail $ fmt request
|
||||||
CaptureRouter _ router' ->
|
CaptureRouter hints router' ->
|
||||||
case pathInfo request of
|
case pathInfo request of
|
||||||
[] -> respond $ Fail $ fmt request
|
[] -> respond $ Fail $ fmt request
|
||||||
-- This case is to handle trailing slashes.
|
-- This case is to handle trailing slashes.
|
||||||
[""] -> respond $ Fail $ fmt request
|
[""] -> respond $ Fail $ fmt request
|
||||||
first : rest
|
first : rest
|
||||||
-> let request' = request { pathInfo = rest }
|
-> let request' = request { pathInfo = rest }
|
||||||
in runRouterEnv fmt router' (first, env) request' respond
|
newEnv = appendPathPiece (CapturePiece hints) env
|
||||||
CaptureAllRouter _ router' ->
|
newEnv' = ((first,) <$> newEnv)
|
||||||
|
in runRouterEnv fmt router' newEnv' request' respond
|
||||||
|
CaptureAllRouter hints router' ->
|
||||||
let segments = pathInfo request
|
let segments = pathInfo request
|
||||||
request' = request { pathInfo = [] }
|
request' = request { pathInfo = [] }
|
||||||
in runRouterEnv fmt router' (segments, env) request' respond
|
newEnv = appendPathPiece (CapturePiece hints) env
|
||||||
|
newEnv' = ((segments,) <$> newEnv)
|
||||||
|
in runRouterEnv fmt router' newEnv' request' respond
|
||||||
RawRouter app ->
|
RawRouter app ->
|
||||||
app env request respond
|
app env request respond
|
||||||
Choice r1 r2 ->
|
Choice r1 r2 ->
|
||||||
runChoice fmt [runRouterEnv fmt r1, runRouterEnv fmt r2] env request respond
|
runChoice fmt [runRouterEnv fmt r1, runRouterEnv fmt r2] env request respond
|
||||||
|
EnvRouter f router' ->
|
||||||
|
runRouterEnv fmt router' (f env) request respond
|
||||||
|
|
||||||
-- | Try a list of routing applications in order.
|
-- | Try a list of routing applications in order.
|
||||||
-- We stop as soon as one fails fatally or succeeds.
|
-- We stop as soon as one fails fatally or succeeds.
|
||||||
|
|
65
servant-server/src/Servant/Server/Internal/RouterEnv.hs
Normal file
65
servant-server/src/Servant/Server/Internal/RouterEnv.hs
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
{-# LANGUAGE DeriveFunctor #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
-- | This module contains the `RouterEnv env` type and associated functions.
|
||||||
|
-- `RouterEnv env` encapsulates the `env` type (as in `Router env a`),
|
||||||
|
-- which contains a tuple-encoded list of url pieces parsed from the incoming request.
|
||||||
|
-- The encapsulation makes it possible to pass more information throughout
|
||||||
|
-- the routing process, and ultimately to the computation of the `Delayed env c`
|
||||||
|
-- associated with each request.
|
||||||
|
-- The type and functions have been designed to be extensible: it should remain easy
|
||||||
|
-- to add a new field to the record and manipulate it.
|
||||||
|
--
|
||||||
|
-- @since 0.20
|
||||||
|
--
|
||||||
|
module Servant.Server.Internal.RouterEnv where
|
||||||
|
|
||||||
|
import Data.Text
|
||||||
|
(Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Typeable
|
||||||
|
(TypeRep)
|
||||||
|
import Network.HTTP.Types.Header
|
||||||
|
(HeaderName)
|
||||||
|
|
||||||
|
data RouterEnv env = RouterEnv
|
||||||
|
{ routedPath :: [PathPiece]
|
||||||
|
, shouldReturnRoutedPath :: Bool
|
||||||
|
, routerEnv :: env
|
||||||
|
}
|
||||||
|
deriving Functor
|
||||||
|
|
||||||
|
emptyEnv :: a -> RouterEnv a
|
||||||
|
emptyEnv v = RouterEnv [] False v
|
||||||
|
|
||||||
|
enableRoutingHeaders :: RouterEnv env -> RouterEnv env
|
||||||
|
enableRoutingHeaders env = env { shouldReturnRoutedPath = True }
|
||||||
|
|
||||||
|
routedPathRepr :: RouterEnv env -> Text
|
||||||
|
routedPathRepr RouterEnv{routedPath = path} =
|
||||||
|
"/" <> T.intercalate "/" (map go $ reverse path)
|
||||||
|
where
|
||||||
|
go (StaticPiece p) = p
|
||||||
|
go (CapturePiece p) = toCaptureTags p
|
||||||
|
|
||||||
|
data PathPiece
|
||||||
|
= StaticPiece Text
|
||||||
|
| CapturePiece [CaptureHint]
|
||||||
|
|
||||||
|
appendPathPiece :: PathPiece -> RouterEnv a -> RouterEnv a
|
||||||
|
appendPathPiece p env@RouterEnv{..} = env { routedPath = p:routedPath }
|
||||||
|
|
||||||
|
data CaptureHint = CaptureHint
|
||||||
|
{ captureName :: Text
|
||||||
|
, captureType :: TypeRep
|
||||||
|
}
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
toCaptureTag :: CaptureHint -> Text
|
||||||
|
toCaptureTag hint = captureName hint <> "::" <> (T.pack . show) (captureType hint)
|
||||||
|
|
||||||
|
toCaptureTags :: [CaptureHint] -> Text
|
||||||
|
toCaptureTags hints = "<" <> T.intercalate "|" (map toCaptureTag hints) <> ">"
|
||||||
|
|
||||||
|
hRoutedPathHeader :: HeaderName
|
||||||
|
hRoutedPathHeader = "Servant-Routed-Path"
|
|
@ -80,7 +80,7 @@ delayed body srv = Delayed
|
||||||
simpleRun :: Delayed () (Handler ())
|
simpleRun :: Delayed () (Handler ())
|
||||||
-> IO ()
|
-> IO ()
|
||||||
simpleRun d = fmap (either ignoreE id) . try $
|
simpleRun d = fmap (either ignoreE id) . try $
|
||||||
runAction d () defaultRequest (\_ -> return ()) (\_ -> FailFatal err500)
|
runAction d (emptyEnv ()) defaultRequest (\_ -> return ()) (\_ -> FailFatal err500)
|
||||||
|
|
||||||
where ignoreE :: SomeException -> ()
|
where ignoreE :: SomeException -> ()
|
||||||
ignoreE = const ()
|
ignoreE = const ()
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE PolyKinds #-}
|
{-# LANGUAGE PolyKinds #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
@ -25,6 +26,8 @@ import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Base64 as Base64
|
import qualified Data.ByteString.Base64 as Base64
|
||||||
import Data.Char
|
import Data.Char
|
||||||
(toUpper)
|
(toUpper)
|
||||||
|
import Data.Map
|
||||||
|
(fromList, notMember)
|
||||||
import Data.Maybe
|
import Data.Maybe
|
||||||
(fromMaybe)
|
(fromMaybe)
|
||||||
import Data.Proxy
|
import Data.Proxy
|
||||||
|
@ -49,20 +52,21 @@ import Network.Wai.Test
|
||||||
import Servant.API
|
import Servant.API
|
||||||
((:<|>) (..), (:>), AuthProtect, BasicAuth,
|
((:<|>) (..), (:>), AuthProtect, BasicAuth,
|
||||||
BasicAuthData (BasicAuthData), Capture, Capture', CaptureAll,
|
BasicAuthData (BasicAuthData), Capture, Capture', CaptureAll,
|
||||||
Delete, EmptyAPI, Fragment, Get, HasStatus (StatusOf), Header,
|
Delete, EmptyAPI, Fragment, Get, GetNoContent,
|
||||||
Headers, HttpVersion, IsSecure (..), JSON, Lenient,
|
HasStatus (StatusOf), Header, Headers, HttpVersion,
|
||||||
NoContent (..), NoContentVerb, NoFraming, OctetStream, Patch,
|
IsSecure (..), JSON, Lenient, NoContent (..), NoContentVerb,
|
||||||
PlainText, Post, Put, QueryFlag, QueryParam, QueryParams, Raw,
|
NoFraming, OctetStream, Patch, PlainText, Post, Put,
|
||||||
RemoteHost, ReqBody, SourceIO, StdMethod (..), Stream, Strict,
|
QueryFlag, QueryParam, QueryParams, Raw, RemoteHost, ReqBody,
|
||||||
UVerb, Union, Verb, WithStatus (..), addHeader)
|
SourceIO, StdMethod (..), Stream, StreamGet, Strict, UVerb,
|
||||||
|
Union, Verb, WithRoutingHeader, WithStatus (..), addHeader)
|
||||||
import Servant.Server
|
import Servant.Server
|
||||||
(Context ((:.), EmptyContext), Handler, Server, Tagged (..),
|
(Context ((:.), EmptyContext), Handler, Server, Tagged (..),
|
||||||
emptyServer, err401, err403, err404, respond, serve,
|
emptyServer, err401, err403, err404, err500, respond, serve,
|
||||||
serveWithContext)
|
serveWithContext)
|
||||||
import Servant.Test.ComprehensiveAPI
|
import Servant.Test.ComprehensiveAPI
|
||||||
import qualified Servant.Types.SourceT as S
|
import qualified Servant.Types.SourceT as S
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
(Spec, context, describe, it, shouldBe, shouldContain)
|
(Spec, context, describe, it, shouldBe, shouldContain, shouldSatisfy)
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
(get, liftIO, matchHeaders, matchStatus, shouldRespondWith,
|
(get, liftIO, matchHeaders, matchStatus, shouldRespondWith,
|
||||||
with, (<:>))
|
with, (<:>))
|
||||||
|
@ -103,6 +107,7 @@ spec = do
|
||||||
miscCombinatorSpec
|
miscCombinatorSpec
|
||||||
basicAuthSpec
|
basicAuthSpec
|
||||||
genAuthSpec
|
genAuthSpec
|
||||||
|
routedPathHeadersSpec
|
||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
-- * verbSpec {{{
|
-- * verbSpec {{{
|
||||||
|
@ -842,6 +847,102 @@ genAuthSpec = do
|
||||||
it "plays nice with subsequent Raw endpoints" $ do
|
it "plays nice with subsequent Raw endpoints" $ do
|
||||||
get "/foo" `shouldRespondWith` 418
|
get "/foo" `shouldRespondWith` 418
|
||||||
|
|
||||||
|
-- }}}
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- * Routed path response headers {{{
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type RoutedPathApi = WithRoutingHeader :>
|
||||||
|
( "content" :> Get '[JSON] Person
|
||||||
|
:<|> "noContent" :> GetNoContent
|
||||||
|
:<|> "header" :> Get '[JSON] (Headers '[Header "H" Int] Person)
|
||||||
|
:<|> "stream" :> StreamGet NoFraming OctetStream (SourceIO BS.ByteString)
|
||||||
|
:<|> "animal" :> ( Capture "legs" Int :> Get '[JSON] Animal
|
||||||
|
:<|> CaptureAll "legs" Int :> Get '[JSON] Animal
|
||||||
|
:<|> Capture "name" String :> Get '[JSON] Animal
|
||||||
|
)
|
||||||
|
) :<|> "withoutHeader" :> Get '[JSON] Person
|
||||||
|
|
||||||
|
routedPathApi :: Proxy RoutedPathApi
|
||||||
|
routedPathApi = Proxy
|
||||||
|
|
||||||
|
routedPathServer :: Server RoutedPathApi
|
||||||
|
routedPathServer =
|
||||||
|
( return alice
|
||||||
|
:<|> return NoContent
|
||||||
|
:<|> return (addHeader 5 alice)
|
||||||
|
:<|> return (S.source ["bytestring"])
|
||||||
|
:<|> (( \case
|
||||||
|
2 -> return tweety
|
||||||
|
4 -> return jerry
|
||||||
|
_ -> throwError err500
|
||||||
|
):<|>( \ legs -> case sum legs of
|
||||||
|
2 -> return tweety
|
||||||
|
4 -> return jerry
|
||||||
|
_ -> throwError err500
|
||||||
|
):<|>( \case
|
||||||
|
"tweety" -> return tweety
|
||||||
|
"jerry" -> return jerry
|
||||||
|
"bob" -> return beholder
|
||||||
|
_ -> throwError err404
|
||||||
|
))
|
||||||
|
) :<|> return alice
|
||||||
|
|
||||||
|
routedPathHeadersSpec :: Spec
|
||||||
|
routedPathHeadersSpec = do
|
||||||
|
describe "Server routing header" $ do
|
||||||
|
with (return $ serve routedPathApi routedPathServer) $ do
|
||||||
|
it "returns the routed path on verbs" $ do
|
||||||
|
response <- THW.request methodGet "/content" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/content")]
|
||||||
|
|
||||||
|
it "returns the routed path on noContent verbs" $ do
|
||||||
|
response <- THW.request methodGet "/noContent" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/noContent")]
|
||||||
|
|
||||||
|
it "returns the routed path on streams" $ do
|
||||||
|
response <- THW.request methodGet "/stream" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/stream")]
|
||||||
|
|
||||||
|
it "plays nice with manually added headers" $ do
|
||||||
|
response <- THW.request methodGet "/header" [] ""
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders response `shouldContain` [("Servant-Routed-Path", "/header")]
|
||||||
|
simpleHeaders response `shouldContain` [("H", "5")]
|
||||||
|
|
||||||
|
it "abstracts captured values" $ do
|
||||||
|
response <- THW.request methodGet "/animal/4" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/animal/<legs::Int>")]
|
||||||
|
|
||||||
|
it "abstracts captured lists" $ do
|
||||||
|
response <- THW.request methodGet "/animal/1/1/0" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/animal/<legs::[Int]>")]
|
||||||
|
|
||||||
|
it "supports backtracking on routing errors" $ do
|
||||||
|
response <- THW.request methodGet "/animal/jerry" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/animal/<name::[Char]>")]
|
||||||
|
|
||||||
|
it "returns the routed path on a failing route" $ do
|
||||||
|
response <- THW.request methodGet "/animal/0" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldContain`
|
||||||
|
[("Servant-Routed-Path", "/animal/<legs::Int>")]
|
||||||
|
|
||||||
|
it "is missing when no route matches" $ do
|
||||||
|
response <- THW.request methodGet "/wrongPath" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldSatisfy`
|
||||||
|
(notMember "Servant-Routed-Path") . fromList
|
||||||
|
|
||||||
|
it "is missing when WithRoutingHeader is missing" $ do
|
||||||
|
response <- THW.request methodGet "/withoutHeader" [] ""
|
||||||
|
liftIO $ simpleHeaders response `shouldSatisfy`
|
||||||
|
(notMember "Servant-Routed-Path") . fromList
|
||||||
|
|
||||||
-- }}}
|
-- }}}
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
-- * UVerb {{{
|
-- * UVerb {{{
|
||||||
|
|
|
@ -38,6 +38,7 @@ library
|
||||||
Servant.API.Capture
|
Servant.API.Capture
|
||||||
Servant.API.ContentTypes
|
Servant.API.ContentTypes
|
||||||
Servant.API.Description
|
Servant.API.Description
|
||||||
|
Servant.API.Environment
|
||||||
Servant.API.Empty
|
Servant.API.Empty
|
||||||
Servant.API.Experimental.Auth
|
Servant.API.Experimental.Auth
|
||||||
Servant.API.Fragment
|
Servant.API.Fragment
|
||||||
|
|
|
@ -7,6 +7,8 @@ module Servant.API (
|
||||||
-- | Type-level combinator for alternative endpoints: @':<|>'@
|
-- | Type-level combinator for alternative endpoints: @':<|>'@
|
||||||
module Servant.API.Empty,
|
module Servant.API.Empty,
|
||||||
-- | Type-level combinator for an empty API: @'EmptyAPI'@
|
-- | Type-level combinator for an empty API: @'EmptyAPI'@
|
||||||
|
module Servant.API.Environment,
|
||||||
|
-- | Type-level combinators to modify the routing environment: @'WithRoutingHeader'@
|
||||||
module Servant.API.Modifiers,
|
module Servant.API.Modifiers,
|
||||||
-- | Type-level modifiers for 'QueryParam', 'Header' and 'ReqBody'.
|
-- | Type-level modifiers for 'QueryParam', 'Header' and 'ReqBody'.
|
||||||
|
|
||||||
|
@ -97,6 +99,8 @@ import Servant.API.Description
|
||||||
(Description, Summary)
|
(Description, Summary)
|
||||||
import Servant.API.Empty
|
import Servant.API.Empty
|
||||||
(EmptyAPI (..))
|
(EmptyAPI (..))
|
||||||
|
import Servant.API.Environment
|
||||||
|
(WithRoutingHeader)
|
||||||
import Servant.API.Experimental.Auth
|
import Servant.API.Experimental.Auth
|
||||||
(AuthProtect)
|
(AuthProtect)
|
||||||
import Servant.API.Fragment
|
import Servant.API.Fragment
|
||||||
|
|
29
servant/src/Servant/API/Environment.hs
Normal file
29
servant/src/Servant/API/Environment.hs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{-# OPTIONS_HADDOCK not-home #-}
|
||||||
|
-- | Define API combinator that modify the behaviour of the routing environment.
|
||||||
|
module Servant.API.Environment (WithRoutingHeader) where
|
||||||
|
|
||||||
|
-- | Modify the behaviour of the following sub-API, such that all endpoint of said API
|
||||||
|
-- return an additional routing header in their response.
|
||||||
|
-- A routing header is a header that specifies which endpoint the incoming request was
|
||||||
|
-- routed to. Endpoint are designated by their path, in which @Capture@ combinators are
|
||||||
|
-- replaced by a capture hint.
|
||||||
|
-- This header can be used by downstream middlewares to gather information about
|
||||||
|
-- individual endpoints, since in most cases a routing header uniquely identifies a
|
||||||
|
-- single endpoint.
|
||||||
|
--
|
||||||
|
-- Example:
|
||||||
|
--
|
||||||
|
-- >>> type MyApi = WithRoutingHeader :> "by-id" :> Capture "id" Int :> Get '[JSON] Foo
|
||||||
|
-- >>> -- GET /by-id/1234 will return a response with the following header:
|
||||||
|
-- >>> -- ("Servant-Routed-Path", "/by-id/<id::Int>")
|
||||||
|
--
|
||||||
|
-- @since 0.20
|
||||||
|
--
|
||||||
|
data WithRoutingHeader
|
||||||
|
|
||||||
|
-- $setup
|
||||||
|
-- >>> import Servant.API
|
||||||
|
-- >>> import Data.Aeson
|
||||||
|
-- >>> import Data.Text
|
||||||
|
-- >>> data Foo
|
||||||
|
-- >>> instance ToJSON Foo where { toJSON = undefined }
|
|
@ -57,6 +57,8 @@ import Servant.API.Alternative
|
||||||
(type (:<|>))
|
(type (:<|>))
|
||||||
import Servant.API.Capture
|
import Servant.API.Capture
|
||||||
(Capture, CaptureAll)
|
(Capture, CaptureAll)
|
||||||
|
import Servant.API.Environment
|
||||||
|
(WithRoutingHeader)
|
||||||
import Servant.API.Fragment
|
import Servant.API.Fragment
|
||||||
import Servant.API.Header
|
import Servant.API.Header
|
||||||
(Header)
|
(Header)
|
||||||
|
@ -130,6 +132,7 @@ type family IsElem endpoint api :: Constraint where
|
||||||
IsElem e (sa :<|> sb) = Or (IsElem e sa) (IsElem e sb)
|
IsElem e (sa :<|> sb) = Or (IsElem e sa) (IsElem e sb)
|
||||||
IsElem (e :> sa) (e :> sb) = IsElem sa sb
|
IsElem (e :> sa) (e :> sb) = IsElem sa sb
|
||||||
IsElem sa (Header sym x :> sb) = IsElem sa sb
|
IsElem sa (Header sym x :> sb) = IsElem sa sb
|
||||||
|
IsElem sa (WithRoutingHeader :> sb) = IsElem sa sb
|
||||||
IsElem sa (ReqBody y x :> sb) = IsElem sa sb
|
IsElem sa (ReqBody y x :> sb) = IsElem sa sb
|
||||||
IsElem (CaptureAll z y :> sa) (CaptureAll x y :> sb)
|
IsElem (CaptureAll z y :> sa) (CaptureAll x y :> sb)
|
||||||
= IsElem sa sb
|
= IsElem sa sb
|
||||||
|
|
Loading…
Reference in New Issue
Block a user