Skip to content

Commit 29d2553

Browse files
authored
Derive HasClient good response status from Verb status (#1469)
1 parent cb294aa commit 29d2553

File tree

12 files changed

+156
-44
lines changed

12 files changed

+156
-44
lines changed

changelog.d/1469

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
synopsis: Derive HasClient good response status from Verb status
2+
prs: #1469
3+
description: {
4+
`HasClient` instances for the `Verb` datatype use `runRequest` in
5+
`clientWithRoute` definitions.
6+
This means that a request performed with `runClientM` will be successful if and
7+
only if the endpoint specify a response status code >=200 and <300.
8+
This change replaces `runRequest` with `runRequestAcceptStatus` in `Verb`
9+
instances for the `HasClient` class, deriving the good response status from
10+
the `Verb` status.
11+
}

servant-client-core/src/Servant/Client/Core/HasClient.hs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import Data.Text
6565
import Data.Proxy
6666
(Proxy (Proxy))
6767
import GHC.TypeLits
68-
(KnownSymbol, symbolVal)
68+
(KnownNat, KnownSymbol, symbolVal)
6969
import Network.HTTP.Types
7070
(Status)
7171
import qualified Network.HTTP.Types as H
@@ -86,6 +86,8 @@ import Servant.API.Generic
8686
, GenericServant, toServant, fromServant)
8787
import Servant.API.ContentTypes
8888
(contentTypes, AllMime (allMime), AllMimeUnrender (allMimeUnrender))
89+
import Servant.API.Status
90+
(statusFromNat)
8991
import Servant.API.TypeLevel (FragmentUnique, AtLeastOneFragment)
9092
import Servant.API.Modifiers
9193
(FoldRequired, RequiredArgument, foldRequiredArgument)
@@ -250,29 +252,32 @@ instance (KnownSymbol capture, ToHttpApiData a, HasClient m sublayout)
250252
instance {-# OVERLAPPABLE #-}
251253
-- Note [Non-Empty Content Types]
252254
( RunClient m, MimeUnrender ct a, ReflectMethod method, cts' ~ (ct ': cts)
255+
, KnownNat status
253256
) => HasClient m (Verb method status cts' a) where
254257
type Client m (Verb method status cts' a) = m a
255258
clientWithRoute _pm Proxy req = do
256-
response <- runRequest req
259+
response <- runRequestAcceptStatus (Just [status]) req
257260
{ requestAccept = fromList $ toList accept
258261
, requestMethod = method
259262
}
260263
response `decodedAs` (Proxy :: Proxy ct)
261264
where
262265
accept = contentTypes (Proxy :: Proxy ct)
263266
method = reflectMethod (Proxy :: Proxy method)
267+
status = statusFromNat (Proxy :: Proxy status)
264268

265269
hoistClientMonad _ _ f ma = f ma
266270

267271
instance {-# OVERLAPPING #-}
268-
( RunClient m, ReflectMethod method
272+
( RunClient m, ReflectMethod method, KnownNat status
269273
) => HasClient m (Verb method status cts NoContent) where
270274
type Client m (Verb method status cts NoContent)
271275
= m NoContent
272276
clientWithRoute _pm Proxy req = do
273-
_response <- runRequest req { requestMethod = method }
277+
_response <- runRequestAcceptStatus (Just [status]) req { requestMethod = method }
274278
return NoContent
275279
where method = reflectMethod (Proxy :: Proxy method)
280+
status = statusFromNat (Proxy :: Proxy status)
276281

277282
hoistClientMonad _ _ f ma = f ma
278283

@@ -289,36 +294,40 @@ instance (RunClient m, ReflectMethod method) =>
289294

290295
instance {-# OVERLAPPING #-}
291296
-- Note [Non-Empty Content Types]
292-
( RunClient m, MimeUnrender ct a, BuildHeadersTo ls
297+
( RunClient m, MimeUnrender ct a, BuildHeadersTo ls, KnownNat status
293298
, ReflectMethod method, cts' ~ (ct ': cts)
294299
) => HasClient m (Verb method status cts' (Headers ls a)) where
295300
type Client m (Verb method status cts' (Headers ls a))
296301
= m (Headers ls a)
297302
clientWithRoute _pm Proxy req = do
298-
response <- runRequest req
303+
response <- runRequestAcceptStatus (Just [status]) req
299304
{ requestMethod = method
300305
, requestAccept = fromList $ toList accept
301306
}
302307
val <- response `decodedAs` (Proxy :: Proxy ct)
303308
return $ Headers { getResponse = val
304309
, getHeadersHList = buildHeadersTo . toList $ responseHeaders response
305310
}
306-
where method = reflectMethod (Proxy :: Proxy method)
307-
accept = contentTypes (Proxy :: Proxy ct)
311+
where
312+
method = reflectMethod (Proxy :: Proxy method)
313+
accept = contentTypes (Proxy :: Proxy ct)
314+
status = statusFromNat (Proxy :: Proxy status)
308315

309316
hoistClientMonad _ _ f ma = f ma
310317

311318
instance {-# OVERLAPPING #-}
312-
( RunClient m, BuildHeadersTo ls, ReflectMethod method
319+
( RunClient m, BuildHeadersTo ls, ReflectMethod method, KnownNat status
313320
) => HasClient m (Verb method status cts (Headers ls NoContent)) where
314321
type Client m (Verb method status cts (Headers ls NoContent))
315322
= m (Headers ls NoContent)
316323
clientWithRoute _pm Proxy req = do
317-
let method = reflectMethod (Proxy :: Proxy method)
318-
response <- runRequest req { requestMethod = method }
324+
response <- runRequestAcceptStatus (Just [status]) req { requestMethod = method }
319325
return $ Headers { getResponse = NoContent
320326
, getHeadersHList = buildHeadersTo . toList $ responseHeaders response
321327
}
328+
where
329+
method = reflectMethod (Proxy :: Proxy method)
330+
status = statusFromNat (Proxy :: Proxy status)
322331

323332
hoistClientMonad _ _ f ma = f ma
324333

@@ -784,7 +793,7 @@ instance ( HasClient m api
784793

785794
-- | Ignore @'Fragment'@ in client functions.
786795
-- See <https://ietf.org/rfc/rfc2616.html#section-15.1.3> for more details.
787-
--
796+
--
788797
-- Example:
789798
--
790799
-- > type MyApi = "books" :> Fragment Text :> Get '[JSON] [Book]
@@ -801,7 +810,7 @@ instance (AtLeastOneFragment api, FragmentUnique (Fragment a :> api), HasClient
801810

802811
type Client m (Fragment a :> api) = Client m api
803812

804-
clientWithRoute pm _ = clientWithRoute pm (Proxy :: Proxy api)
813+
clientWithRoute pm _ = clientWithRoute pm (Proxy :: Proxy api)
805814

806815
hoistClientMonad pm _ = hoistClientMonad pm (Proxy :: Proxy api)
807816

servant-client/servant-client.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ test-suite spec
8989
main-is: Spec.hs
9090
other-modules:
9191
Servant.BasicAuthSpec
92+
Servant.BrokenSpec
9293
Servant.ClientTestUtils
9394
Servant.ConnectionErrorSpec
9495
Servant.FailSpec

servant-client/src/Servant/Client/Internal/HttpClient.hs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import GHC.Generics
6363
import Network.HTTP.Media
6464
(renderHeader)
6565
import Network.HTTP.Types
66-
(hContentType, renderQuery, statusCode, urlEncode, Status)
66+
(hContentType, renderQuery, statusIsSuccessful, urlEncode, Status)
6767
import Servant.Client.Core
6868

6969
import qualified Network.HTTP.Client as Client
@@ -179,10 +179,9 @@ performRequest acceptStatus req = do
179179

180180
response <- maybe (requestWithoutCookieJar m request) (requestWithCookieJar m request) cookieJar'
181181
let status = Client.responseStatus response
182-
status_code = statusCode status
183182
ourResponse = clientResponseToResponse id response
184183
goodStatus = case acceptStatus of
185-
Nothing -> status_code >= 200 && status_code < 300
184+
Nothing -> statusIsSuccessful status
186185
Just good -> status `elem` good
187186
unless goodStatus $ do
188187
throwError $ mkFailureResponse burl req ourResponse

servant-client/src/Servant/Client/Internal/HttpClient/Streaming.hs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import Data.Time.Clock
4747
(getCurrentTime)
4848
import GHC.Generics
4949
import Network.HTTP.Types
50-
(Status, statusCode)
50+
(Status, statusIsSuccessful)
5151

5252
import qualified Network.HTTP.Client as Client
5353

@@ -163,10 +163,9 @@ performRequest acceptStatus req = do
163163
now' <- getCurrentTime
164164
atomically $ modifyTVar' cj (fst . Client.updateCookieJar response request now')
165165
let status = Client.responseStatus response
166-
status_code = statusCode status
167166
ourResponse = clientResponseToResponse id response
168167
goodStatus = case acceptStatus of
169-
Nothing -> status_code >= 200 && status_code < 300
168+
Nothing -> statusIsSuccessful status
170169
Just good -> status `elem` good
171170
unless goodStatus $ do
172171
throwError $ mkFailureResponse burl req ourResponse
@@ -182,10 +181,9 @@ performWithStreamingRequest req k = do
182181
ClientM $ lift $ lift $ Codensity $ \k1 ->
183182
Client.withResponse request m $ \res -> do
184183
let status = Client.responseStatus res
185-
status_code = statusCode status
186184

187185
-- we throw FailureResponse in IO :(
188-
unless (status_code >= 200 && status_code < 300) $ do
186+
unless (statusIsSuccessful status) $ do
189187
b <- BSL.fromChunks <$> Client.brConsume (Client.responseBody res)
190188
throwIO $ mkFailureResponse burl req (clientResponseToResponse (const b) res)
191189

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE TypeOperators #-}
3+
{-# OPTIONS_GHC -freduction-depth=100 #-}
4+
{-# OPTIONS_GHC -fno-warn-orphans #-}
5+
{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
6+
7+
module Servant.BrokenSpec (spec) where
8+
9+
import Prelude ()
10+
import Prelude.Compat
11+
12+
import Data.Monoid ()
13+
import Data.Proxy
14+
import qualified Network.HTTP.Types as HTTP
15+
import Test.Hspec
16+
17+
import Servant.API
18+
((:<|>) ((:<|>)), (:>), JSON, Verb, Get, StdMethod (GET))
19+
import Servant.Client
20+
import Servant.ClientTestUtils
21+
import Servant.Server
22+
23+
-- * api for testing inconsistencies between client and server
24+
25+
type Get201 = Verb 'GET 201
26+
type Get301 = Verb 'GET 301
27+
28+
type BrokenAPI =
29+
-- the server should respond with 200, but returns 201
30+
"get200" :> Get201 '[JSON] ()
31+
-- the server should respond with 307, but returns 301
32+
:<|> "get307" :> Get301 '[JSON] ()
33+
34+
brokenApi :: Proxy BrokenAPI
35+
brokenApi = Proxy
36+
37+
brokenServer :: Application
38+
brokenServer = serve brokenApi (pure () :<|> pure ())
39+
40+
type PublicAPI =
41+
-- the client expects 200
42+
"get200" :> Get '[JSON] ()
43+
-- the client expects 307
44+
:<|> "get307" :> Get307 '[JSON] ()
45+
46+
publicApi :: Proxy PublicAPI
47+
publicApi = Proxy
48+
49+
get200Client :: ClientM ()
50+
get307Client :: ClientM ()
51+
get200Client :<|> get307Client = client publicApi
52+
53+
54+
spec :: Spec
55+
spec = describe "Servant.BrokenSpec" $ do
56+
brokenSpec
57+
58+
brokenSpec :: Spec
59+
brokenSpec = beforeAll (startWaiApp brokenServer) $ afterAll endWaiApp $ do
60+
context "client returns errors for inconsistencies between client and server api" $ do
61+
it "reports FailureResponse with wrong 2xx status code" $ \(_, baseUrl) -> do
62+
res <- runClient get200Client baseUrl
63+
case res of
64+
Left (FailureResponse _ r) | responseStatusCode r == HTTP.status201 -> return ()
65+
_ -> fail $ "expected 201 broken response, but got " <> show res
66+
67+
it "reports FailureResponse with wrong 3xx status code" $ \(_, baseUrl) -> do
68+
res <- runClient get307Client baseUrl
69+
case res of
70+
Left (FailureResponse _ r) | responseStatusCode r == HTTP.status301 -> return ()
71+
_ -> fail $ "expected 301 broken response, but got " <> show res

servant-client/test/Servant/ClientTestUtils.hs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import Servant.API
6464
JSON, MimeRender (mimeRender), MimeUnrender (mimeUnrender),
6565
NoContent (NoContent), PlainText, Post, QueryFlag, QueryParam,
6666
QueryParams, Raw, ReqBody, StdMethod (GET), ToHttpApiData (..), UVerb, Union,
67-
WithStatus (WithStatus), NamedRoutes, addHeader)
67+
Verb, WithStatus (WithStatus), NamedRoutes, addHeader)
6868
import Servant.API.Generic ((:-))
6969
import Servant.Client
7070
import qualified Servant.Client.Core.Auth as Auth
@@ -118,9 +118,16 @@ data OtherRoutes mode = OtherRoutes
118118
{ something :: mode :- "something" :> Get '[JSON] [String]
119119
} deriving Generic
120120

121+
-- Get for HTTP 307 Temporary Redirect
122+
type Get307 = Verb 'GET 307
123+
121124
type Api =
122125
Get '[JSON] Person
123126
:<|> "get" :> Get '[JSON] Person
127+
-- This endpoint returns a response with status code 307 Temporary Redirect,
128+
-- different from the ones in the 2xx successful class, to test derivation
129+
-- of clients' api.
130+
:<|> "get307" :> Get307 '[PlainText] Text
124131
:<|> "deleteEmpty" :> DeleteNoContent
125132
:<|> "capture" :> Capture "name" String :> Get '[JSON,FormUrlEncoded] Person
126133
:<|> "captureAll" :> CaptureAll "names" String :> Get '[JSON] [Person]
@@ -154,12 +161,12 @@ type Api =
154161
:<|> "uverb-get-created" :> UVerb 'GET '[PlainText] '[WithStatus 201 Person]
155162
:<|> NamedRoutes RecordRoutes
156163

157-
158164
api :: Proxy Api
159165
api = Proxy
160166

161167
getRoot :: ClientM Person
162168
getGet :: ClientM Person
169+
getGet307 :: ClientM Text
163170
getDeleteEmpty :: ClientM NoContent
164171
getCapture :: String -> ClientM Person
165172
getCaptureAll :: [String] -> ClientM [Person]
@@ -186,6 +193,7 @@ recordRoutes :: RecordRoutes (AsClientT ClientM)
186193

187194
getRoot
188195
:<|> getGet
196+
:<|> getGet307
189197
:<|> getDeleteEmpty
190198
:<|> getCapture
191199
:<|> getCaptureAll
@@ -212,6 +220,7 @@ server :: Application
212220
server = serve api (
213221
return carol
214222
:<|> return alice
223+
:<|> return "redirecting"
215224
:<|> return NoContent
216225
:<|> (\ name -> return $ Person name 0)
217226
:<|> (\ names -> return (zipWith Person names [0..]))
@@ -252,6 +261,8 @@ server = serve api (
252261
}
253262
)
254263

264+
-- * api for testing failures
265+
255266
type FailApi =
256267
"get" :> Raw
257268
:<|> "capture" :> Capture "name" String :> Raw
@@ -266,7 +277,7 @@ failServer = serve failApi (
266277
:<|> (\ _capture -> Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "application/json")] "")
267278
:<|> (Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "fooooo")] "")
268279
:<|> (Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "application/x-www-form-urlencoded"), ("X-Example1", "1"), ("X-Example2", "foo")] "")
269-
)
280+
)
270281

271282
-- * basic auth stuff
272283

servant-client/test/Servant/FailSpec.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ failSpec = beforeAll (startWaiApp failServer) $ afterAll endWaiApp $ do
3838

3939
context "client returns errors appropriately" $ do
4040
it "reports FailureResponse" $ \(_, baseUrl) -> do
41-
let (_ :<|> _ :<|> getDeleteEmpty :<|> _) = client api
41+
let (_ :<|> _ :<|> _ :<|> getDeleteEmpty :<|> _) = client api
4242
Left res <- runClient getDeleteEmpty baseUrl
4343
case res of
4444
FailureResponse _ r | responseStatusCode r == HTTP.status404 -> return ()
4545
_ -> fail $ "expected 404 response, but got " <> show res
4646

4747
it "reports DecodeFailure" $ \(_, baseUrl) -> do
48-
let (_ :<|> _ :<|> _ :<|> getCapture :<|> _) = client api
48+
let (_ :<|> _ :<|> _ :<|> _ :<|> getCapture :<|> _) = client api
4949
Left res <- runClient (getCapture "foo") baseUrl
5050
case res of
5151
DecodeFailure _ _ -> return ()
@@ -72,7 +72,7 @@ failSpec = beforeAll (startWaiApp failServer) $ afterAll endWaiApp $ do
7272
_ -> fail $ "expected UnsupportedContentType, but got " <> show res
7373

7474
it "reports InvalidContentTypeHeader" $ \(_, baseUrl) -> do
75-
let (_ :<|> _ :<|> _ :<|> _ :<|> _ :<|> getBody :<|> _) = client api
75+
let (_ :<|> _ :<|> _ :<|> _ :<|> _ :<|> _ :<|> getBody :<|> _) = client api
7676
Left res <- runClient (getBody alice) baseUrl
7777
case res of
7878
InvalidContentTypeHeader _ -> return ()

0 commit comments

Comments
 (0)