Skip to content

Commit aa6d96a

Browse files
committed
New combinator to return routed path in response headers
This commit introduces a new type-level combinator, `WithRoutingHeader`. It modifies 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'` 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:CaptureSingle>") ``` 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).
1 parent 5f39372 commit aa6d96a

File tree

9 files changed

+160
-34
lines changed

9 files changed

+160
-34
lines changed

servant-server/servant-server.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ library
4646
Servant.Server.Internal.DelayedIO
4747
Servant.Server.Internal.ErrorFormatter
4848
Servant.Server.Internal.Handler
49+
Servant.Server.Internal.RouterEnv
4950
Servant.Server.Internal.RouteResult
5051
Servant.Server.Internal.Router
5152
Servant.Server.Internal.RoutingApplication

servant-server/src/Servant/Server/Internal.hs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module Servant.Server.Internal
2626
, module Servant.Server.Internal.ErrorFormatter
2727
, module Servant.Server.Internal.Handler
2828
, module Servant.Server.Internal.Router
29+
, module Servant.Server.Internal.RouterEnv
2930
, module Servant.Server.Internal.RouteResult
3031
, module Servant.Server.Internal.RoutingApplication
3132
, module Servant.Server.Internal.ServerError
@@ -76,7 +77,7 @@ import Servant.API
7677
QueryParam', QueryParams, Raw, ReflectMethod (reflectMethod),
7778
RemoteHost, ReqBody', SBool (..), SBoolI (..), SourceIO,
7879
Stream, StreamBody', Summary, ToSourceIO (..), Vault, Verb,
79-
WithNamedContext, NamedRoutes)
80+
WithNamedContext, WithRoutingHeader, NamedRoutes)
8081
import Servant.API.Generic (GenericMode(..), ToServant, ToServantApi, GServantProduct, toServant, fromServant)
8182
import Servant.API.ContentTypes
8283
(AcceptHeader (..), AllCTRender (..), AllCTUnrender (..),
@@ -103,6 +104,7 @@ import Servant.Server.Internal.ErrorFormatter
103104
import Servant.Server.Internal.Handler
104105
import Servant.Server.Internal.Router
105106
import Servant.Server.Internal.RouteResult
107+
import Servant.Server.Internal.RouterEnv
106108
import Servant.Server.Internal.RoutingApplication
107109
import Servant.Server.Internal.ServerError
108110

@@ -241,6 +243,20 @@ instance (KnownSymbol capture, FromHttpApiData a
241243
formatError = urlParseErrorFormatter $ getContextEntry (mkContextWithErrorFormatter context)
242244
hint = CaptureHint (T.pack $ symbolVal $ Proxy @capture) CaptureList
243245

246+
-- | Using 'WithRoutingHeaders' in one of the endpoints for your API,
247+
-- will automatically add routing headers to the response generated by the server.
248+
instance ( HasServer api context
249+
, HasContextEntry (MkContextWithErrorFormatter context) ErrorFormatters
250+
)
251+
=> HasServer (WithRoutingHeader :> api) context where
252+
253+
type ServerT (WithRoutingHeader :> api) m = ServerT api m
254+
255+
hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt s
256+
257+
route _ context d =
258+
EnvRouter enableRoutingHeaders $ route (Proxy :: Proxy api) context d
259+
244260
allowedMethodHead :: Method -> Request -> Bool
245261
allowedMethodHead method request = method == methodGet && requestMethod request == methodHead
246262

@@ -292,7 +308,10 @@ noContentRouter method status action = leafRouter route'
292308
route' env request respond =
293309
runAction (action `addMethodCheck` methodCheck method request)
294310
env request respond $ \ _output ->
295-
Route $ responseLBS status [] ""
311+
let headers = if (shouldReturnRoutedPath env)
312+
then [(hRoutedPathHeader, cs $ routedPathRepr env)]
313+
else []
314+
in Route $ responseLBS status headers ""
296315

297316
instance {-# OVERLAPPABLE #-}
298317
( AllCTRender ctypes a, ReflectMethod method, KnownNat status

servant-server/src/Servant/Server/Internal/Delayed.hs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ import Control.Monad.Reader
1414
(ask)
1515
import Control.Monad.Trans.Resource
1616
(ResourceT, runResourceT)
17+
import Data.String.Conversions
18+
(cs)
1719
import Network.Wai
18-
(Request, Response)
20+
(Request, Response, mapResponseHeaders)
1921

2022
import Servant.Server.Internal.DelayedIO
2123
import Servant.Server.Internal.Handler
24+
import Servant.Server.Internal.RouterEnv
25+
(RouterEnv (..), hRoutedPathHeader, routedPathRepr)
2226
import Servant.Server.Internal.RouteResult
2327
import Servant.Server.Internal.ServerError
2428

@@ -228,12 +232,12 @@ passToServer Delayed{..} x =
228232
-- This should only be called once per request; otherwise the guarantees about
229233
-- effect and HTTP error ordering break down.
230234
runDelayed :: Delayed env a
231-
-> env
235+
-> RouterEnv env
232236
-> Request
233237
-> ResourceT IO (RouteResult a)
234238
runDelayed Delayed{..} env = runDelayedIO $ do
235239
r <- ask
236-
c <- capturesD env
240+
c <- capturesD $ routerEnv env
237241
methodD
238242
a <- authD
239243
acceptD
@@ -248,7 +252,7 @@ runDelayed Delayed{..} env = runDelayedIO $ do
248252
-- Also takes a continuation for how to turn the
249253
-- result of the delayed server into a response.
250254
runAction :: Delayed env (Handler a)
251-
-> env
255+
-> RouterEnv env
252256
-> Request
253257
-> (RouteResult Response -> IO r)
254258
-> (a -> RouteResult Response)
@@ -261,8 +265,12 @@ runAction action env req respond k = runResourceT $
261265
go (Route a) = liftIO $ do
262266
e <- runHandler a
263267
case e of
264-
Left err -> return . Route $ responseServerError err
265-
Right x -> return $! k x
268+
Left err -> return . Route . withRoutingHeaders $ responseServerError err
269+
Right x -> return $! withRoutingHeaders <$> k x
270+
withRoutingHeaders :: Response -> Response
271+
withRoutingHeaders = if shouldReturnRoutedPath env
272+
then mapResponseHeaders ((hRoutedPathHeader, cs $ routedPathRepr env) :)
273+
else id
266274

267275
{- Note [Existential Record Update]
268276
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

servant-server/src/Servant/Server/Internal/Router.hs

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
{-# LANGUAGE DeriveFunctor #-}
33
{-# LANGUAGE GADTs #-}
44
{-# LANGUAGE OverloadedStrings #-}
5+
{-# LANGUAGE TupleSections #-}
56
module Servant.Server.Internal.Router where
67

78
import Prelude ()
@@ -20,27 +21,13 @@ import qualified Data.Text as T
2021
import Network.Wai
2122
(Response, pathInfo)
2223
import Servant.Server.Internal.ErrorFormatter
24+
import Servant.Server.Internal.RouterEnv
2325
import Servant.Server.Internal.RouteResult
2426
import Servant.Server.Internal.RoutingApplication
2527
import Servant.Server.Internal.ServerError
2628

2729
type Router env = Router' env RoutingApplication
2830

29-
data CaptureHint = CaptureHint
30-
{ captureName :: Text
31-
, captureType :: CaptureType
32-
}
33-
deriving (Show, Eq)
34-
35-
data CaptureType = CaptureList | CaptureSingle
36-
deriving (Show, Eq)
37-
38-
toCaptureTag :: CaptureHint -> Text
39-
toCaptureTag hint = captureName hint <> "::" <> (T.pack . show) (captureType hint)
40-
41-
toCaptureTags :: [CaptureHint] -> Text
42-
toCaptureTags hints = "<" <> T.intercalate "|" (map toCaptureTag hints) <> ">"
43-
4431
-- | Internal representation of a router.
4532
--
4633
-- The first argument describes an environment type that is
@@ -49,7 +36,7 @@ toCaptureTags hints = "<" <> T.intercalate "|" (map toCaptureTag hints) <> ">"
4936
-- components that can be used to process captures.
5037
--
5138
data Router' env a =
52-
StaticRouter (Map Text (Router' env a)) [env -> a]
39+
StaticRouter (Map Text (Router' env a)) [RouterEnv env -> a]
5340
-- ^ the map contains routers for subpaths (first path component used
5441
-- for lookup and removed afterwards), the list contains handlers
5542
-- for the empty path, to be tried in order
@@ -59,10 +46,12 @@ data Router' env a =
5946
| CaptureAllRouter [CaptureHint] (Router' ([Text], env) a)
6047
-- ^ all path components are passed to the child router in its
6148
-- environment and are removed afterwards
62-
| RawRouter (env -> a)
49+
| RawRouter (RouterEnv env -> a)
6350
-- ^ to be used for routes we do not know anything about
6451
| Choice (Router' env a) (Router' env a)
6552
-- ^ left-biased choice between two routers
53+
| EnvRouter (RouterEnv env -> RouterEnv env) (Router' env a)
54+
-- ^ modifies the environment, and passes it to the child router
6655
deriving Functor
6756

6857
-- | Smart constructor for a single static path component.
@@ -72,7 +61,7 @@ pathRouter t r = StaticRouter (M.singleton t r) []
7261
-- | Smart constructor for a leaf, i.e., a router that expects
7362
-- the empty path.
7463
--
75-
leafRouter :: (env -> a) -> Router' env a
64+
leafRouter :: (RouterEnv env -> a) -> Router' env a
7665
leafRouter l = StaticRouter M.empty [l]
7766

7867
-- | Smart constructor for the choice between routers.
@@ -127,6 +116,7 @@ routerStructure (Choice r1 r2) =
127116
ChoiceStructure
128117
(routerStructure r1)
129118
(routerStructure r2)
119+
routerStructure (EnvRouter _ r) = routerStructure r
130120

131121
-- | Compare the structure of two routers. Ignores capture hints.
132122
--
@@ -183,9 +173,9 @@ tweakResponse f = fmap (\a -> \req cont -> a req (cont . f))
183173

184174
-- | Interpret a router as an application.
185175
runRouter :: NotFoundErrorFormatter -> Router () -> RoutingApplication
186-
runRouter fmt r = runRouterEnv fmt r ()
176+
runRouter fmt r = runRouterEnv fmt r $ emptyEnv ()
187177

188-
runRouterEnv :: NotFoundErrorFormatter -> Router env -> env -> RoutingApplication
178+
runRouterEnv :: NotFoundErrorFormatter -> Router env -> RouterEnv env -> RoutingApplication
189179
runRouterEnv fmt router env request respond =
190180
case router of
191181
StaticRouter table ls ->
@@ -195,24 +185,31 @@ runRouterEnv fmt router env request respond =
195185
[""] -> runChoice fmt ls env request respond
196186
first : rest | Just router' <- M.lookup first table
197187
-> let request' = request { pathInfo = rest }
198-
in runRouterEnv fmt router' env request' respond
188+
newEnv = appendPiece (StaticPiece first) env
189+
in runRouterEnv fmt router' newEnv request' respond
199190
_ -> respond $ Fail $ fmt request
200-
CaptureRouter _ router' ->
191+
CaptureRouter hints router' ->
201192
case pathInfo request of
202193
[] -> respond $ Fail $ fmt request
203194
-- This case is to handle trailing slashes.
204195
[""] -> respond $ Fail $ fmt request
205196
first : rest
206197
-> let request' = request { pathInfo = rest }
207-
in runRouterEnv fmt router' (first, env) request' respond
208-
CaptureAllRouter _ router' ->
198+
newEnv = appendPiece (CapturePiece hints) env
199+
newEnv' = ((first,) <$> newEnv)
200+
in runRouterEnv fmt router' newEnv' request' respond
201+
CaptureAllRouter hints router' ->
209202
let segments = pathInfo request
210203
request' = request { pathInfo = [] }
211-
in runRouterEnv fmt router' (segments, env) request' respond
204+
newEnv = appendPiece (CapturePiece hints) env
205+
newEnv' = ((segments,) <$> newEnv)
206+
in runRouterEnv fmt router' newEnv' request' respond
212207
RawRouter app ->
213208
app env request respond
214209
Choice r1 r2 ->
215210
runChoice fmt [runRouterEnv fmt r1, runRouterEnv fmt r2] env request respond
211+
EnvRouter f router' ->
212+
runRouterEnv fmt router' (f env) request respond
216213

217214
-- | Try a list of routing applications in order.
218215
-- We stop as soon as one fails fatally or succeeds.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{-# LANGUAGE DeriveFunctor #-}
2+
{-# LANGUAGE OverloadedStrings #-}
3+
{-# LANGUAGE RecordWildCards #-}
4+
5+
module Servant.Server.Internal.RouterEnv where
6+
7+
import Data.Text
8+
(Text)
9+
import qualified Data.Text as T
10+
import Network.HTTP.Types.Header
11+
(HeaderName)
12+
13+
data RouterEnv env = RouterEnv
14+
{ routedPath :: [PathPiece]
15+
, shouldReturnRoutedPath :: Bool
16+
, routerEnv :: env
17+
}
18+
deriving Functor
19+
20+
emptyEnv :: a -> RouterEnv a
21+
emptyEnv v = RouterEnv [] False v
22+
23+
enableRoutingHeaders :: RouterEnv env -> RouterEnv env
24+
enableRoutingHeaders RouterEnv{..} = RouterEnv
25+
{ shouldReturnRoutedPath = True
26+
, ..
27+
}
28+
29+
routedPathRepr :: RouterEnv env -> Text
30+
routedPathRepr RouterEnv{routedPath = path} =
31+
"/" <> T.intercalate "/" (map go $ reverse path)
32+
where
33+
go (StaticPiece p) = p
34+
go (CapturePiece p) = toCaptureTags p
35+
36+
37+
data PathPiece
38+
= StaticPiece Text
39+
| CapturePiece [CaptureHint]
40+
41+
appendPiece :: PathPiece -> RouterEnv a -> RouterEnv a
42+
appendPiece p RouterEnv{..} = RouterEnv
43+
{ routedPath = p:routedPath
44+
, ..
45+
}
46+
47+
48+
data CaptureHint = CaptureHint
49+
{ captureName :: Text
50+
, captureType :: CaptureType
51+
}
52+
deriving (Show, Eq)
53+
54+
data CaptureType = CaptureList | CaptureSingle
55+
deriving (Show, Eq)
56+
57+
toCaptureTag :: CaptureHint -> Text
58+
toCaptureTag hint = captureName hint <> "::" <> (T.pack . show) (captureType hint)
59+
60+
toCaptureTags :: [CaptureHint] -> Text
61+
toCaptureTags hints = "<" <> T.intercalate "|" (map toCaptureTag hints) <> ">"
62+
63+
64+
hRoutedPathHeader :: HeaderName
65+
hRoutedPathHeader = "Servant-Routed-Path"

servant-server/test/Servant/Server/Internal/RoutingApplicationSpec.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ delayed body srv = Delayed
8080
simpleRun :: Delayed () (Handler ())
8181
-> IO ()
8282
simpleRun d = fmap (either ignoreE id) . try $
83-
runAction d () defaultRequest (\_ -> return ()) (\_ -> FailFatal err500)
83+
runAction d (emptyEnv ()) defaultRequest (\_ -> return ()) (\_ -> FailFatal err500)
8484

8585
where ignoreE :: SomeException -> ()
8686
ignoreE = const ()

servant/servant.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ library
3838
Servant.API.Capture
3939
Servant.API.ContentTypes
4040
Servant.API.Description
41+
Servant.API.Environment
4142
Servant.API.Empty
4243
Servant.API.Experimental.Auth
4344
Servant.API.Fragment

servant/src/Servant/API.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module Servant.API (
77
-- | Type-level combinator for alternative endpoints: @':<|>'@
88
module Servant.API.Empty,
99
-- | Type-level combinator for an empty API: @'EmptyAPI'@
10+
module Servant.API.Environment,
11+
-- | Type-level combinators to modify the routing environment: @'WithRoutingHeader'@
1012
module Servant.API.Modifiers,
1113
-- | Type-level modifiers for 'QueryParam', 'Header' and 'ReqBody'.
1214

@@ -96,6 +98,8 @@ import Servant.API.Description
9698
(Description, Summary)
9799
import Servant.API.Empty
98100
(EmptyAPI (..))
101+
import Servant.API.Environment
102+
(WithRoutingHeader)
99103
import Servant.API.Experimental.Auth
100104
(AuthProtect)
101105
import Servant.API.Fragment
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{-# LANGUAGE DeriveDataTypeable #-}
2+
{-# OPTIONS_HADDOCK not-home #-}
3+
-- | Define API combinator that modify the behaviour of the routing environment.
4+
module Servant.API.Environment (WithRoutingHeader) where
5+
6+
import Data.Typeable
7+
(Typeable)
8+
9+
-- | Modify the behaviour of the following sub-API, such that all endpoint of said API
10+
-- return an additional routing header in their response.
11+
-- A routing header is a header that specifies which endpoint the incoming request was
12+
-- routed to. Endpoint are designated by their path, in which @Capture@ combinators are
13+
-- replaced by a capture hint.
14+
-- This header can be used by downstream middlewares to gather information about
15+
-- individual endpoints, since in most cases a routing header uniquely identifies a
16+
-- single endpoint.
17+
--
18+
-- Example:
19+
--
20+
-- >>> type MyApi = WithRoutingHeader :> "by-id" :> Capture "id" Int :> Get '[JSON] Foo
21+
-- >>> -- GET /by-id/1234 will return a response with the following header:
22+
-- >>> -- ("Servant-Routed-Path", "/by-id/<id:CaptureSingle>")
23+
data WithRoutingHeader
24+
deriving (Typeable)
25+
26+
-- $setup
27+
-- >>> import Servant.API
28+
-- >>> import Data.Aeson
29+
-- >>> import Data.Text
30+
-- >>> data Foo
31+
-- >>> instance ToJSON Foo where { toJSON = undefined }

0 commit comments

Comments
 (0)