Merge 363d142571
into 8f081bd9ad
This commit is contained in:
commit
3930e4d86e
|
@ -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,8 @@ library
|
|||
Servant.Server.Internal.DelayedIO
|
||||
Servant.Server.Internal.ErrorFormatter
|
||||
Servant.Server.Internal.Handler
|
||||
Servant.Server.Internal.Redirect
|
||||
Servant.Server.Internal.RouterEnv
|
||||
Servant.Server.Internal.RouteResult
|
||||
Servant.Server.Internal.Router
|
||||
Servant.Server.Internal.RoutingApplication
|
||||
|
@ -142,6 +144,7 @@ test-suite spec
|
|||
, base-compat
|
||||
, base64-bytestring
|
||||
, bytestring
|
||||
, containers
|
||||
, http-types
|
||||
, mtl
|
||||
, resourcet
|
||||
|
|
|
@ -27,6 +27,7 @@ module Servant.Server.Internal
|
|||
, module Servant.Server.Internal.ErrorFormatter
|
||||
, module Servant.Server.Internal.Handler
|
||||
, module Servant.Server.Internal.Router
|
||||
, module Servant.Server.Internal.RouterEnv
|
||||
, module Servant.Server.Internal.RouteResult
|
||||
, module Servant.Server.Internal.RoutingApplication
|
||||
, module Servant.Server.Internal.ServerError
|
||||
|
@ -74,10 +75,10 @@ import Servant.API
|
|||
CaptureAll, Description, EmptyAPI, Fragment,
|
||||
FramingRender (..), FramingUnrender (..), FromSourceIO (..),
|
||||
Header', If, IsSecure (..), NoContentVerb, QueryFlag,
|
||||
QueryParam', QueryParams, Raw, ReflectMethod (reflectMethod),
|
||||
QueryParam', QueryParams, Raw, Redirect, ReflectMethod (reflectMethod),
|
||||
RemoteHost, ReqBody', SBool (..), SBoolI (..), SourceIO,
|
||||
Stream, StreamBody', Summary, ToSourceIO (..), Vault, Verb,
|
||||
WithNamedContext, NamedRoutes)
|
||||
WithNamedContext, WithRoutingHeader, NamedRoutes)
|
||||
import Servant.API.Generic (GenericMode(..), ToServant, ToServantApi, GServantProduct, toServant, fromServant)
|
||||
import Servant.API.ContentTypes
|
||||
(AcceptHeader (..), AllCTRender (..), AllCTUnrender (..),
|
||||
|
@ -89,7 +90,7 @@ import Servant.API.Modifiers
|
|||
import Servant.API.ResponseHeaders
|
||||
(GetHeaders, Headers, getHeaders, getResponse)
|
||||
import Servant.API.Status
|
||||
(statusFromNat)
|
||||
(Redirection, statusFromNat)
|
||||
import qualified Servant.Types.SourceT as S
|
||||
import Servant.API.TypeErrors
|
||||
import Web.HttpApiData
|
||||
|
@ -104,8 +105,10 @@ import Servant.Server.Internal.Delayed
|
|||
import Servant.Server.Internal.DelayedIO
|
||||
import Servant.Server.Internal.ErrorFormatter
|
||||
import Servant.Server.Internal.Handler
|
||||
import Servant.Server.Internal.Redirect
|
||||
import Servant.Server.Internal.Router
|
||||
import Servant.Server.Internal.RouteResult
|
||||
import Servant.Server.Internal.RouterEnv
|
||||
import Servant.Server.Internal.RoutingApplication
|
||||
import Servant.Server.Internal.ServerError
|
||||
|
||||
|
@ -244,6 +247,40 @@ instance (KnownSymbol capture, FromHttpApiData a, Typeable a
|
|||
formatError = urlParseErrorFormatter $ getContextEntry (mkContextWithErrorFormatter context)
|
||||
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
|
||||
|
||||
-- | TODO: Documentation
|
||||
instance ( HasServer api context
|
||||
, HasContextEntry (MkContextWithErrorFormatter context) ErrorFormatters
|
||||
, KnownSymbol location
|
||||
, AllStatusesInClass Redirection api
|
||||
)
|
||||
=> HasServer (Redirect location :> api) context where
|
||||
|
||||
type ServerT (Redirect location :> api) m = ServerT api m
|
||||
|
||||
hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt s
|
||||
|
||||
route _ context d =
|
||||
EnvRouter
|
||||
(withLocationHeader $ symbolVal $ Proxy @location)
|
||||
(route (Proxy :: Proxy api) context d)
|
||||
|
||||
allowedMethodHead :: Method -> Request -> Bool
|
||||
allowedMethodHead method request = method == methodGet && requestMethod request == methodHead
|
||||
|
||||
|
@ -295,7 +332,10 @@ noContentRouter method status action = leafRouter route'
|
|||
route' env request respond =
|
||||
runAction (action `addMethodCheck` methodCheck method request)
|
||||
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 #-}
|
||||
( AllCTRender ctypes a, ReflectMethod method, KnownNat status
|
||||
|
|
|
@ -14,11 +14,15 @@ import Control.Monad.Reader
|
|||
(ask)
|
||||
import Control.Monad.Trans.Resource
|
||||
(ResourceT, runResourceT)
|
||||
import Data.String.Conversions
|
||||
(cs)
|
||||
import Network.Wai
|
||||
(Request, Response)
|
||||
(Request, Response, mapResponseHeaders)
|
||||
|
||||
import Servant.Server.Internal.DelayedIO
|
||||
import Servant.Server.Internal.Handler
|
||||
import Servant.Server.Internal.RouterEnv
|
||||
(RouterEnv (..), hLocationHeader, hRoutedPathHeader, routedPathRepr)
|
||||
import Servant.Server.Internal.RouteResult
|
||||
import Servant.Server.Internal.ServerError
|
||||
|
||||
|
@ -228,12 +232,12 @@ passToServer Delayed{..} x =
|
|||
-- This should only be called once per request; otherwise the guarantees about
|
||||
-- effect and HTTP error ordering break down.
|
||||
runDelayed :: Delayed env a
|
||||
-> env
|
||||
-> RouterEnv env
|
||||
-> Request
|
||||
-> ResourceT IO (RouteResult a)
|
||||
runDelayed Delayed{..} env = runDelayedIO $ do
|
||||
r <- ask
|
||||
c <- capturesD env
|
||||
c <- capturesD $ routerEnv env
|
||||
methodD
|
||||
a <- authD
|
||||
acceptD
|
||||
|
@ -248,7 +252,7 @@ runDelayed Delayed{..} env = runDelayedIO $ do
|
|||
-- Also takes a continuation for how to turn the
|
||||
-- result of the delayed server into a response.
|
||||
runAction :: Delayed env (Handler a)
|
||||
-> env
|
||||
-> RouterEnv env
|
||||
-> Request
|
||||
-> (RouteResult Response -> IO r)
|
||||
-> (a -> RouteResult Response)
|
||||
|
@ -261,8 +265,18 @@ runAction action env req respond k = runResourceT $
|
|||
go (Route a) = liftIO $ do
|
||||
e <- runHandler a
|
||||
case e of
|
||||
Left err -> return . Route $ responseServerError err
|
||||
Right x -> return $! k x
|
||||
Left err -> return . Route . withRoutingHeaders $ responseServerError err
|
||||
Right x -> return $! withHeaders <$> k x
|
||||
withRoutingHeaders :: Response -> Response
|
||||
withRoutingHeaders = if shouldReturnRoutedPath env
|
||||
then mapResponseHeaders ((hRoutedPathHeader, cs $ routedPathRepr env) :)
|
||||
else id
|
||||
withLocationHeader :: Response -> Response
|
||||
withLocationHeader = case locationHeader env of
|
||||
Nothing -> id
|
||||
Just location -> mapResponseHeaders ((hLocationHeader, cs location) :)
|
||||
withHeaders :: Response -> Response
|
||||
withHeaders = withLocationHeader . withRoutingHeaders
|
||||
|
||||
{- Note [Existential Record Update]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE PolyKinds #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeOperators #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
|
||||
module Servant.Server.Internal.Redirect where
|
||||
|
||||
import Data.SOP.Constraint
|
||||
(All)
|
||||
import GHC.TypeLits
|
||||
(Nat, ErrorMessage(..), TypeError)
|
||||
import Servant.API
|
||||
((:>), (:<|>), Raw, Statuses, Stream, UVerb, Verb)
|
||||
import Servant.API.Status
|
||||
(HasStatusClass, KnownStatusClass)
|
||||
|
||||
type family (as :: [k]) ++ (bs :: [k]) :: [k] where
|
||||
'[] ++ bs = bs
|
||||
(a ': as) ++ bs = a ': (as ++ bs)
|
||||
|
||||
-- | A type class to gather all statically declared HTTP status codes of an api
|
||||
class HasApiStatuses a where
|
||||
type ApiStatuses a :: [Nat]
|
||||
|
||||
instance HasApiStatuses (Verb method status ctypes a) where
|
||||
type ApiStatuses (Verb _ status _ _) = '[status]
|
||||
|
||||
instance HasApiStatuses (Stream method status framing ctypes a) where
|
||||
type ApiStatuses (Stream _ status _ _ _) = '[status]
|
||||
|
||||
instance HasApiStatuses (UVerb method ctypes as) where
|
||||
type ApiStatuses (UVerb _ _ as) = Statuses as
|
||||
|
||||
instance HasApiStatuses Raw where
|
||||
type ApiStatuses Raw = TypeError ('Text "cannot observe the HTTP statuses of a Raw API")
|
||||
|
||||
instance (HasApiStatuses api) => HasApiStatuses (api' :> api) where
|
||||
type ApiStatuses (_ :> api) = ApiStatuses api
|
||||
|
||||
instance (HasApiStatuses api1, HasApiStatuses api2) => HasApiStatuses (api1 :<|> api2) where
|
||||
type ApiStatuses (api1 :<|> api2) = (ApiStatuses api1) ++ (ApiStatuses api2)
|
||||
|
||||
-- | A type class to check that all statically declared HTTP status codes of an api
|
||||
-- belong to the same status class, as defined by @KnownStatusClass@.
|
||||
class AllStatusesInClass c api
|
||||
|
||||
instance ( HasApiStatuses api
|
||||
, KnownStatusClass c
|
||||
, All (HasStatusClass c) (ApiStatuses api)
|
||||
) => AllStatusesInClass c api
|
|
@ -2,6 +2,7 @@
|
|||
{-# LANGUAGE DeriveFunctor #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
module Servant.Server.Internal.Router where
|
||||
|
||||
import Prelude ()
|
||||
|
@ -17,29 +18,16 @@ import qualified Data.Map as M
|
|||
import Data.Text
|
||||
(Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Typeable
|
||||
(TypeRep)
|
||||
import Network.Wai
|
||||
(Response, pathInfo)
|
||||
import Servant.Server.Internal.ErrorFormatter
|
||||
import Servant.Server.Internal.RouterEnv
|
||||
import Servant.Server.Internal.RouteResult
|
||||
import Servant.Server.Internal.RoutingApplication
|
||||
import Servant.Server.Internal.ServerError
|
||||
|
||||
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.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
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
|
||||
-- for lookup and removed afterwards), the list contains handlers
|
||||
-- for the empty path, to be tried in order
|
||||
|
@ -58,10 +46,13 @@ data Router' env a =
|
|||
| CaptureAllRouter [CaptureHint] (Router' ([Text], env) a)
|
||||
-- ^ all path components are passed to the child router in its
|
||||
-- environment and are removed afterwards
|
||||
| RawRouter (env -> a)
|
||||
| RawRouter (RouterEnv env -> a)
|
||||
-- ^ to be used for routes we do not know anything about
|
||||
| Choice (Router' env a) (Router' env a)
|
||||
-- ^ 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
|
||||
|
||||
-- | 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
|
||||
-- the empty path.
|
||||
--
|
||||
leafRouter :: (env -> a) -> Router' env a
|
||||
leafRouter :: (RouterEnv env -> a) -> Router' env a
|
||||
leafRouter l = StaticRouter M.empty [l]
|
||||
|
||||
-- | Smart constructor for the choice between routers.
|
||||
|
@ -126,6 +117,7 @@ routerStructure (Choice r1 r2) =
|
|||
ChoiceStructure
|
||||
(routerStructure r1)
|
||||
(routerStructure r2)
|
||||
routerStructure (EnvRouter _ r) = routerStructure r
|
||||
|
||||
-- | 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.
|
||||
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 =
|
||||
case router of
|
||||
StaticRouter table ls ->
|
||||
|
@ -184,24 +176,31 @@ runRouterEnv fmt router env request respond =
|
|||
[""] -> runChoice fmt ls env request respond
|
||||
first : rest | Just router' <- M.lookup first table
|
||||
-> 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
|
||||
CaptureRouter _ router' ->
|
||||
CaptureRouter hints router' ->
|
||||
case pathInfo request of
|
||||
[] -> respond $ Fail $ fmt request
|
||||
-- This case is to handle trailing slashes.
|
||||
[""] -> respond $ Fail $ fmt request
|
||||
first : rest
|
||||
-> let request' = request { pathInfo = rest }
|
||||
in runRouterEnv fmt router' (first, env) request' respond
|
||||
CaptureAllRouter _ router' ->
|
||||
newEnv = appendPathPiece (CapturePiece hints) env
|
||||
newEnv' = ((first,) <$> newEnv)
|
||||
in runRouterEnv fmt router' newEnv' request' respond
|
||||
CaptureAllRouter hints router' ->
|
||||
let segments = pathInfo request
|
||||
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 ->
|
||||
app env request respond
|
||||
Choice r1 r2 ->
|
||||
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.
|
||||
-- We stop as soon as one fails fatally or succeeds.
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
{-# 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
|
||||
{ locationHeader :: Maybe String
|
||||
, routedPath :: [PathPiece]
|
||||
, shouldReturnRoutedPath :: Bool
|
||||
, routerEnv :: env
|
||||
}
|
||||
deriving Functor
|
||||
|
||||
emptyEnv :: a -> RouterEnv a
|
||||
emptyEnv v = RouterEnv
|
||||
{ locationHeader = Nothing
|
||||
, routedPath = []
|
||||
, shouldReturnRoutedPath = False
|
||||
, routerEnv = 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) <> ">"
|
||||
|
||||
withLocationHeader :: String -> RouterEnv a -> RouterEnv a
|
||||
withLocationHeader loc env = env { locationHeader = Just loc}
|
||||
|
||||
hRoutedPathHeader :: HeaderName
|
||||
hRoutedPathHeader = "Servant-Routed-Path"
|
||||
|
||||
hLocationHeader :: HeaderName
|
||||
hLocationHeader = "Location"
|
|
@ -80,7 +80,7 @@ delayed body srv = Delayed
|
|||
simpleRun :: Delayed () (Handler ())
|
||||
-> IO ()
|
||||
simpleRun d = fmap (either ignoreE id) . try $
|
||||
runAction d () defaultRequest (\_ -> return ()) (\_ -> FailFatal err500)
|
||||
runAction d (emptyEnv ()) defaultRequest (\_ -> return ()) (\_ -> FailFatal err500)
|
||||
|
||||
where ignoreE :: SomeException -> ()
|
||||
ignoreE = const ()
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PolyKinds #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
@ -25,6 +26,8 @@ import qualified Data.ByteString as BS
|
|||
import qualified Data.ByteString.Base64 as Base64
|
||||
import Data.Char
|
||||
(toUpper)
|
||||
import Data.Map
|
||||
(fromList, notMember)
|
||||
import Data.Maybe
|
||||
(fromMaybe)
|
||||
import Data.Proxy
|
||||
|
@ -49,20 +52,21 @@ import Network.Wai.Test
|
|||
import Servant.API
|
||||
((:<|>) (..), (:>), AuthProtect, BasicAuth,
|
||||
BasicAuthData (BasicAuthData), Capture, Capture', CaptureAll,
|
||||
Delete, EmptyAPI, Fragment, Get, HasStatus (StatusOf), Header,
|
||||
Headers, HttpVersion, IsSecure (..), JSON, Lenient,
|
||||
NoContent (..), NoContentVerb, NoFraming, OctetStream, Patch,
|
||||
PlainText, Post, Put, QueryFlag, QueryParam, QueryParams, Raw,
|
||||
RemoteHost, ReqBody, SourceIO, StdMethod (..), Stream, Strict,
|
||||
UVerb, Union, Verb, WithStatus (..), addHeader)
|
||||
Delete, EmptyAPI, Fragment, Get, GetNoContent,
|
||||
HasStatus (StatusOf), Header, Headers, HttpVersion,
|
||||
IsSecure (..), JSON, Lenient, NoContent (..), NoContentVerb,
|
||||
NoFraming, OctetStream, Patch, PlainText, Post, Put,
|
||||
QueryFlag, QueryParam, QueryParams, Raw, Redirect, RemoteHost, ReqBody,
|
||||
SourceIO, StdMethod (..), Stream, StreamGet, Strict, UVerb,
|
||||
Union, Verb, WithRoutingHeader, WithStatus (..), addHeader)
|
||||
import Servant.Server
|
||||
(Context ((:.), EmptyContext), Handler, Server, Tagged (..),
|
||||
emptyServer, err401, err403, err404, respond, serve,
|
||||
emptyServer, err401, err403, err404, err500, respond, serve,
|
||||
serveWithContext)
|
||||
import Servant.Test.ComprehensiveAPI
|
||||
import qualified Servant.Types.SourceT as S
|
||||
import Test.Hspec
|
||||
(Spec, context, describe, it, shouldBe, shouldContain)
|
||||
(Spec, context, describe, it, shouldBe, shouldContain, shouldSatisfy)
|
||||
import Test.Hspec.Wai
|
||||
(get, liftIO, matchHeaders, matchStatus, shouldRespondWith,
|
||||
with, (<:>))
|
||||
|
@ -103,6 +107,8 @@ spec = do
|
|||
miscCombinatorSpec
|
||||
basicAuthSpec
|
||||
genAuthSpec
|
||||
routedPathHeadersSpec
|
||||
redirectionSpec
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- * verbSpec {{{
|
||||
|
@ -842,6 +848,150 @@ genAuthSpec = do
|
|||
it "plays nice with subsequent Raw endpoints" $ do
|
||||
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
|
||||
|
||||
-- }}}
|
||||
------------------------------------------------------------------------------
|
||||
-- * Redirection {{{
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
type RedirectionApi
|
||||
= "old-api" :> Redirect "/new-api" :>
|
||||
( Verb 'GET 307 '[JSON] NoContent
|
||||
:<|> Verb 'POST 307 '[JSON] NoContent
|
||||
:<|> "sub-api" :> Redirect "/new-api/sub-api" :> Verb 'GET 301 '[JSON] NoContent
|
||||
)
|
||||
:<|> "new-api" :>
|
||||
( Get '[JSON] Person
|
||||
:<|> Post '[JSON] Person
|
||||
:<|> "sub-api" :> Get '[JSON] Person
|
||||
)
|
||||
|
||||
redirectionApi :: Proxy RedirectionApi
|
||||
redirectionApi = Proxy
|
||||
|
||||
redirectionServer :: Server RedirectionApi
|
||||
redirectionServer =
|
||||
( return NoContent
|
||||
:<|> return NoContent
|
||||
:<|> return NoContent
|
||||
) :<|>
|
||||
( return alice
|
||||
:<|> return alice
|
||||
:<|> return alice
|
||||
)
|
||||
|
||||
redirectionSpec :: Spec
|
||||
redirectionSpec = do
|
||||
describe "Redirect combinator" $ do
|
||||
with (return $ serve redirectionApi redirectionServer) $ do
|
||||
it "fills the Location header" $ do
|
||||
response <- THW.request methodGet "/old-api" [] ""
|
||||
liftIO $ simpleHeaders response `shouldContain`
|
||||
[("Location", "/new-api")]
|
||||
it "gets trumped by more specific redirections" $ do
|
||||
response <- THW.request methodGet "/old-api/sub-api" [] ""
|
||||
liftIO $ simpleHeaders response `shouldContain`
|
||||
[("Location", "/new-api/sub-api")]
|
||||
it "only fills Location header in nested apis" $ do
|
||||
response <- THW.request methodGet "/new-api" [] ""
|
||||
liftIO $ simpleHeaders response `shouldSatisfy`
|
||||
(notMember "Location") . fromList
|
||||
|
||||
-- }}}
|
||||
------------------------------------------------------------------------------
|
||||
-- * UVerb {{{
|
||||
|
|
|
@ -38,6 +38,7 @@ library
|
|||
Servant.API.Capture
|
||||
Servant.API.ContentTypes
|
||||
Servant.API.Description
|
||||
Servant.API.Environment
|
||||
Servant.API.Empty
|
||||
Servant.API.Experimental.Auth
|
||||
Servant.API.Fragment
|
||||
|
|
|
@ -7,6 +7,8 @@ module Servant.API (
|
|||
-- | Type-level combinator for alternative endpoints: @':<|>'@
|
||||
module Servant.API.Empty,
|
||||
-- | 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,
|
||||
-- | Type-level modifiers for 'QueryParam', 'Header' and 'ReqBody'.
|
||||
|
||||
|
@ -97,6 +99,8 @@ import Servant.API.Description
|
|||
(Description, Summary)
|
||||
import Servant.API.Empty
|
||||
(EmptyAPI (..))
|
||||
import Servant.API.Environment
|
||||
(Redirect, WithRoutingHeader)
|
||||
import Servant.API.Experimental.Auth
|
||||
(AuthProtect)
|
||||
import Servant.API.Fragment
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE KindSignatures #-}
|
||||
|
||||
{-# OPTIONS_HADDOCK not-home #-}
|
||||
-- | Define API combinator that modify the behaviour of the routing environment.
|
||||
module Servant.API.Environment (Redirect, WithRoutingHeader) where
|
||||
|
||||
import GHC.TypeLits
|
||||
(Symbol)
|
||||
|
||||
-- | 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
|
||||
|
||||
-- | Modify the behaviour of the following sub-API, such that all endpoints of said API
|
||||
-- return a "Location" header, set to the value of @location@ type variable. An API using
|
||||
-- the @Redirect@ combinator **does not typecheck** if any of the endpoints below the
|
||||
-- combinator returns a status code outside the 3xx range, or if it is used to redirect
|
||||
-- a @Raw@ API (because we cannot guarantee anything about them).
|
||||
--
|
||||
-- For instance, the following API doesn't have a @HasServer@ instance:
|
||||
--
|
||||
-- >>> type BadApi
|
||||
-- >>> = "old-api" :> Redirect "/new-api" :> Get '[JSON] Foo
|
||||
-- >>> :<|> "new-api" :> Get '[JSON] Foo
|
||||
-- >>> -- @Get@ is an alias for @Verb 'GET 200@
|
||||
--
|
||||
-- Whereas this one does:
|
||||
--
|
||||
-- >>> type GoodApi
|
||||
-- >>> = "old-api" :> Redirect "/new-api" :> Verb 'GET 301 '[JSON] Foo
|
||||
-- >>> :<|> "new-api" :> Get '[JSON] Foo
|
||||
-- >>> -- GET /old-api will return a response with status 301 and the following header:
|
||||
-- >>> -- ("Location", "/new-api")
|
||||
--
|
||||
-- @since TODO
|
||||
--
|
||||
data Redirect (location :: Symbol)
|
||||
|
||||
-- $setup
|
||||
-- >>> import Servant.API
|
||||
-- >>> import Data.Aeson
|
||||
-- >>> import Data.Text
|
||||
-- >>> data Foo
|
||||
-- >>> instance ToJSON Foo where { toJSON = undefined }
|
|
@ -1,10 +1,19 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
-- Flexible instances is necessary on GHC 8.4 and earlier
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE StandaloneKindSignatures #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeOperators #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
|
||||
module Servant.API.Status where
|
||||
|
||||
import GHC.TypeLits (KnownNat, natVal)
|
||||
import Data.Kind
|
||||
(Constraint)
|
||||
import GHC.TypeLits
|
||||
(type (+), type (<=?), ErrorMessage(..), KnownNat, Nat, Symbol, TypeError, natVal)
|
||||
import Network.HTTP.Types.Status
|
||||
|
||||
-- | Retrieve a known or unknown Status from a KnownNat
|
||||
|
@ -158,3 +167,41 @@ instance KnownStatus 505 where
|
|||
|
||||
instance KnownStatus 511 where
|
||||
statusVal _ = status511
|
||||
|
||||
-- | Witness that a type-level natural number corresponds to a class
|
||||
-- of HTTP error codes
|
||||
class KnownNat c => KnownStatusClass c where
|
||||
type TextualClass c :: Symbol
|
||||
|
||||
type Informational = 100
|
||||
instance KnownStatusClass Informational where
|
||||
type TextualClass Informational = "Informational"
|
||||
|
||||
type Successful = 200
|
||||
instance KnownStatusClass Successful where
|
||||
type TextualClass Successful = "Successful"
|
||||
|
||||
type Redirection = 300
|
||||
instance KnownStatusClass Redirection where
|
||||
type TextualClass Redirection = "Redirection"
|
||||
|
||||
type ClientError = 400
|
||||
instance KnownStatusClass ClientError where
|
||||
type TextualClass ClientError = "ClientError"
|
||||
|
||||
type ServerError = 500
|
||||
instance KnownStatusClass ServerError where
|
||||
type TextualClass ServerError = "ServerError"
|
||||
|
||||
-- | Witness that a type-level status belongs to its given class.
|
||||
-- Raises a custom error when it does not.
|
||||
class (KnownStatus s, KnownStatusClass c) => HasStatusClass c s
|
||||
|
||||
type ClassCheck :: Bool -> Bool -> Nat -> Nat -> Constraint
|
||||
type family ClassCheck a b c s where
|
||||
ClassCheck 'True 'True _ _ = ()
|
||||
ClassCheck _ _ c s = TypeError ('Text "HTTP status code " ':<>: 'ShowType s ':<>: 'Text " does not belong to class " ':<>: 'Text (TextualClass c))
|
||||
|
||||
instance ( KnownStatus s, KnownStatusClass c
|
||||
, ClassCheck (c <=? s) (s <=? c + 99) c s
|
||||
) => HasStatusClass c s
|
||||
|
|
Loading…
Reference in New Issue