Skip to content

Commit a551eb6

Browse files
committed
Do the accept check before the body check.
This is a reasonably simple attempt at fixing #460. By moving the accept check to a place before the body check, we can make it recoverable (the body check is irreversible, so everything done after the body check has to fail fatally). The advantage is that we can now specify routes offering different content types modularly. Failure to match one is not fatal, and will result in subsequent routes being tried. The disadvantage is that we hereby bump the error priority of the 406 status code. If a request contains a bad accept header and a bad body, we now get 406 rather than 400. This deviates from the HTTP decision diagram we try to follow, but seems like an acceptable compromise for now.
1 parent caf0209 commit a551eb6

File tree

5 files changed

+57
-13
lines changed

5 files changed

+57
-13
lines changed

servant-server/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
efficiently. Functions `layout` and `layoutWithContext` have been
88
added to visualize the router layout for debugging purposes. Test
99
cases for expected router layouts have been added.
10+
* If an endpoint is discovered to have a non-matching "accept header",
11+
this is now a recoverable rather than a fatal failure, allowing
12+
different endpoints for the same route, but with different content
13+
types to be specified modularly.
1014
* Export `throwError` from module `Servant`
1115
* Add `Handler` type synonym
1216

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,17 @@ methodCheck method request
154154
| allowedMethod method request = return ()
155155
| otherwise = delayedFail err405
156156

157+
-- This has switched between using 'Fail' and 'FailFatal' a number of
158+
-- times. If the 'acceptCheck' is run after the body check (which would
159+
-- be morally right), then we have to set this to 'FailFatal', because
160+
-- the body check is not reversible, and therefore backtracking after the
161+
-- body check is no longer an option. However, we now run the accept
162+
-- check before the body check and can therefore afford to make it
163+
-- recoverable.
157164
acceptCheck :: (AllMime list) => Proxy list -> B.ByteString -> DelayedIO ()
158165
acceptCheck proxy accH
159166
| canHandleAcceptH proxy (AcceptHeader accH) = return ()
160-
| otherwise = delayedFailFatal err406
167+
| otherwise = delayedFail err406
161168

162169
methodRouter :: (AllCTRender ctypes a)
163170
=> Method -> Proxy ctypes -> Status

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,16 +203,22 @@ addBodyCheck Delayed{..} new =
203203
} -- Note [Existential Record Update]
204204

205205

206-
-- | Add an accept header check to the end of the body block.
207-
-- The accept header check should occur after the body check,
208-
-- but this will be the case, because the accept header check
209-
-- is only scheduled by the method combinators.
206+
-- | Add an accept header check to the beginning of the body
207+
-- block. There is a tradeoff here. In principle, we'd like
208+
-- to take a bad body (400) response take precedence over a
209+
-- failed accept check (406). BUT to allow streaming the body,
210+
-- we cannot run the body check and then still backtrack.
211+
-- We therefore do the accept check before the body check,
212+
-- when we can still backtrack. There are other solutions to
213+
-- this, but they'd be more complicated (such as delaying the
214+
-- body check further so that it can still be run in a situation
215+
-- where we'd otherwise report 406).
210216
addAcceptCheck :: Delayed env a
211217
-> DelayedIO ()
212218
-> Delayed env a
213219
addAcceptCheck Delayed{..} new =
214220
Delayed
215-
{ bodyD = bodyD <* new
221+
{ bodyD = new *> bodyD
216222
, ..
217223
} -- Note [Existential Record Update]
218224

servant-server/test/Servant/Server/ErrorSpec.hs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ errorOrderApi = Proxy
5353
errorOrderServer :: Server ErrorOrderApi
5454
errorOrderServer = \_ _ _ -> throwE err402
5555

56+
-- On error priorities:
57+
--
58+
-- We originally had
59+
--
60+
-- 404, 405, 401, 415, 400, 406, 402
61+
--
62+
-- but we changed this to
63+
--
64+
-- 404, 405, 401, 406, 415, 400, 402
65+
--
66+
-- for servant-0.7.
67+
--
68+
-- This change is due to the body check being irreversible (to support
69+
-- streaming). Any check done after the body check has to be made fatal,
70+
-- breaking modularity. We've therefore moved the accept check before
71+
-- the body check, to allow it being recoverable and modular, and this
72+
-- goes along with promoting the error priority of 406.
5673
errorOrderSpec :: Spec
5774
errorOrderSpec =
5875
describe "HTTP error order" $
@@ -86,18 +103,18 @@ errorOrderSpec =
86103
request goodMethod goodUrl [badAuth, badContentType, badAccept] badBody
87104
`shouldRespondWith` 401
88105

89-
it "has 415 as its fourth highest priority error" $ do
106+
it "has 406 as its fourth highest priority error" $ do
90107
request goodMethod goodUrl [goodAuth, badContentType, badAccept] badBody
108+
`shouldRespondWith` 406
109+
110+
it "has 415 as its fifth highest priority error" $ do
111+
request goodMethod goodUrl [goodAuth, badContentType, goodAccept] badBody
91112
`shouldRespondWith` 415
92113

93-
it "has 400 as its fifth highest priority error" $ do
94-
request goodMethod goodUrl [goodAuth, goodContentType, badAccept] badBody
114+
it "has 400 as its sixth highest priority error" $ do
115+
request goodMethod goodUrl [goodAuth, goodContentType, goodAccept] badBody
95116
`shouldRespondWith` 400
96117

97-
it "has 406 as its sixth highest priority error" $ do
98-
request goodMethod goodUrl [goodAuth, goodContentType, badAccept] goodBody
99-
`shouldRespondWith` 406
100-
101118
it "has handler-level errors as last priority" $ do
102119
request goodMethod goodUrl [goodAuth, goodContentType, goodAccept] goodBody
103120
`shouldRespondWith` 402

servant-server/test/Servant/ServerSpec.hs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ type VerbApi method status
9999
:<|> "noContent" :> Verb method status '[JSON] NoContent
100100
:<|> "header" :> Verb method status '[JSON] (Headers '[Header "H" Int] Person)
101101
:<|> "headerNC" :> Verb method status '[JSON] (Headers '[Header "H" Int] NoContent)
102+
:<|> "accept" :> ( Verb method status '[JSON] Person
103+
:<|> Verb method status '[PlainText] String
104+
)
102105

103106
verbSpec :: Spec
104107
verbSpec = describe "Servant.API.Verb" $ do
@@ -107,6 +110,7 @@ verbSpec = describe "Servant.API.Verb" $ do
107110
:<|> return NoContent
108111
:<|> return (addHeader 5 alice)
109112
:<|> return (addHeader 10 NoContent)
113+
:<|> (return alice :<|> return "B")
110114
get200 = Proxy :: Proxy (VerbApi 'GET 200)
111115
post210 = Proxy :: Proxy (VerbApi 'POST 210)
112116
put203 = Proxy :: Proxy (VerbApi 'PUT 203)
@@ -161,6 +165,12 @@ verbSpec = describe "Servant.API.Verb" $ do
161165
[(hAccept, "application/json")] ""
162166
liftIO $ statusCode (simpleStatus response) `shouldBe` status
163167

168+
unless (status `elem` [214, 215] || method == methodHead) $
169+
it "allows modular specification of supported content types" $ do
170+
response <- THW.request method "/accept" [(hAccept, "text/plain")] ""
171+
liftIO $ statusCode (simpleStatus response) `shouldBe` status
172+
liftIO $ simpleBody response `shouldBe` "B"
173+
164174
it "sets the Content-Type header" $ do
165175
response <- THW.request method "" [] ""
166176
liftIO $ simpleHeaders response `shouldContain`

0 commit comments

Comments
 (0)