1 {-# LANGUAGE OverloadedStrings #-}
2 {-# LANGUAGE Rank2Types #-}
3 {-# LANGUAGE NoMonomorphismRestriction #-}
4 module Hspec.Client where
6 import Control.Arrow (left)
7 import Control.Concurrent (ThreadId, forkIO, killThread)
8 import Control.Monad (Monad(..), when)
10 import Data.Either (Either(..))
11 import Data.Eq (Eq(..))
12 import Data.Function (($), (.))
13 import Data.Functor ((<$>))
15 import Data.Map.Strict (Map)
16 import Data.Maybe (Maybe(..), fromJust)
17 import Data.Proxy (Proxy(..))
18 import Data.Semigroup (Semigroup(..))
19 import Data.String (String)
20 import Data.Text (Text)
21 import Prelude (fromIntegral, (+))
24 import Test.Hspec.Wai (liftIO)
26 import Test.Tasty.Hspec
27 import Text.Read (readMaybe)
28 import Text.Show (Show(..))
29 import qualified Data.ByteString.Base64 as BS64
30 import qualified Data.ByteString as BS
31 import qualified Data.ByteString.Lazy as BSL
32 import qualified Data.Map.Strict as Map
33 import qualified Data.Text as Text
34 import qualified Data.Text.Encoding as Text
35 import qualified Data.Text.Lazy as TL
36 import qualified Data.Text.Lazy.Encoding as TL
37 import qualified Network.HTTP.Client as Client
38 import qualified Network.HTTP.Types as HTTP
39 import qualified Network.Socket as Net
40 import qualified Network.Wai as Wai
41 import qualified Network.Wai.Handler.Warp as Warp
42 import qualified Test.Hspec.Wai as Wai
46 api = "auth" </> basicAuth @User "realm"
47 <.> get @() @'[PlainText]
48 <!> "body" </> body @() @'[PlainText]
49 <.> post @() @'[PlainText]
55 route_auth User{} = return ()
56 route_body (ServerBodyArg a) = return ()
63 alice = User "Alice" "pass" True 19
64 bob = User "Bob" "pass" False 31
74 instance ServerBasicAuth User where
75 serverBasicAuth user pass =
77 case Map.lookup user users of
78 Nothing -> BasicAuth_NoSuchUser
80 | user_pass == pass ->
82 then BasicAuth_Authorized u
83 else BasicAuth_Unauthorized
84 | otherwise -> BasicAuth_BadPassword
86 users :: Map Text User
89 (\u -> (user_name u, u)) <$>
97 instance FromJSON User
98 instance FromForm User
100 instance Arbitrary User where
101 arbitrary = User <$> arbitrary <*> arbitrary
104 hspec :: IO [TestTree]
105 hspec = testSpecs $ describe "Client" $
106 beforeAll (runTestServer srv) $
107 afterAll killTestServer $ do
108 describe "BasicAuth" $ do
109 it "can allow user (200)" $ \TestServer{..} -> do
110 runClient env (cli_auth (user_name alice) (user_pass alice))
111 `shouldReturn` Right ()
112 it "can deny user (401)" $ \TestServer{..} -> do
113 Left (ClientError_FailureResponse r) <-
114 runClient env $ cli_auth "no-user" (user_pass alice)
115 clientResStatus r `shouldBe` HTTP.Status 401 "Unauthorized"
116 it "can deny pass (401)" $ \TestServer{..} -> do
117 Left (ClientError_FailureResponse r) <-
118 runClient env $ cli_auth (user_name alice) "no-pass"
119 clientResStatus r `shouldBe` HTTP.Status 401 "Unauthorized"
120 it "can deny auth (403)" $ \TestServer{..} -> do
121 Left (ClientError_FailureResponse r) <-
122 runClient env $ cli_auth (user_name bob) (user_pass bob)
123 clientResStatus r `shouldBe` HTTP.Status 403 "Forbidden"
126 import Control.Arrow (left)
127 import Control.Concurrent.STM (atomically)
128 import Control.Concurrent.STM.TVar (newTVar, readTVar)
129 import Control.Exception (bracket, fromException)
130 import Control.Monad.Error.Class (throwError)
131 import Data.Char (chr, isPrint)
132 import Data.Foldable (forM_, toList)
133 import Data.Maybe (isJust, listToMaybe)
134 import Data.Monoid ()
136 import Data.Semigroup ((<>))
137 import GHC.Generics (Generic)
138 import qualified Network.HTTP.Client as Client
139 import qualified Network.HTTP.Types as HTTP
140 import qualified Network.Wai as Wai
141 import Network.Wai.Handler.Warp
142 import System.IO.Unsafe (unsafePerformIO)
144 import Test.Hspec.QuickCheck
146 import Test.QuickCheck
147 import Web.FormUrlEncoded (FromForm, ToForm)
149 -- This declaration simply checks that all instances are in place.
150 _ = client comprehensiveAPIWithoutStreaming
153 spec = describe "Servant.Client" $ do
167 } deriving (Eq, Show, Generic)
169 instance ToJSON Person
170 instance FromJSON Person
172 instance ToForm Person
173 instance FromForm Person
175 instance Arbitrary Person where
176 arbitrary = Person <$> arbitrary <*> arbitrary
179 alice = Person "Alice" 42
182 bob = Person "Carol" 17
184 type TestHeaders = '[Header "X-Example1" Int, Header "X-Example2" String]
188 :<|> "get" :> Get '[JSON] Person
189 :<|> "deleteEmpty" :> DeleteNoContent '[JSON] NoContent
190 :<|> "capture" :> Capture "name" String :> Get '[JSON,FormUrlEncoded] Person
191 :<|> "captureAll" :> CaptureAll "names" String :> Get '[JSON] [Person]
192 :<|> "body" :> ReqBody '[FormUrlEncoded,JSON] Person :> Post '[JSON] Person
193 :<|> "param" :> QueryParam "name" String :> Get '[FormUrlEncoded,JSON] Person
194 :<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person]
195 :<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool
196 :<|> "rawSuccess" :> Raw
197 :<|> "rawFailure" :> Raw
199 Capture "first" String :>
200 QueryParam "second" Int :>
202 ReqBody '[JSON] [(String, [Rational])] :>
203 Get '[JSON] (String, Maybe Int, Bool, [(String, [Rational])])
204 :<|> "headers" :> Get '[JSON] (Headers TestHeaders Bool)
205 :<|> "deleteContentType" :> DeleteNoContent '[JSON] NoContent
206 :<|> "redirectWithCookie" :> Raw
207 :<|> "empty" :> EmptyAPI
212 getRoot :: ClientM Person
213 getGet :: ClientM Person
214 getDeleteEmpty :: ClientM NoContent
215 getCapture :: String -> ClientM Person
216 getCaptureAll :: [String] -> ClientM [Person]
217 getBody :: Person -> ClientM Person
218 getQueryParam :: Maybe String -> ClientM Person
219 getQueryParams :: [String] -> ClientM [Person]
220 getQueryFlag :: Bool -> ClientM Bool
221 getRawSuccess :: HTTP.Method -> ClientM Response
222 getRawFailure :: HTTP.Method -> ClientM Response
223 getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])]
224 -> ClientM (String, Maybe Int, Bool, [(String, [Rational])])
225 getRespHeaders :: ClientM (Headers TestHeaders Bool)
226 getDeleteContentType :: ClientM NoContent
227 getRedirectWithCookie :: HTTP.Method -> ClientM Response
242 :<|> getDeleteContentType
243 :<|> getRedirectWithCookie
244 :<|> EmptyClient = client api
246 server :: Application
250 :<|> return NoContent
251 :<|> (\ name -> return $ Person name 0)
252 :<|> (\ names -> return (zipWith Person names [0..]))
254 :<|> (\ name -> case name of
255 Just "alice" -> return alice
256 Just n -> throwError $ ServerError 400 (n ++ " not found") "" []
257 Nothing -> throwError $ ServerError 400 "missing parameter" "" [])
258 :<|> (\ names -> return (zipWith Person names [0..]))
260 :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.ok200 [] "rawSuccess")
261 :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.badRequest400 [] "rawFailure")
262 :<|> (\ a b c d -> return (a, b, c, d))
263 :<|> (return $ addHeader 1729 $ addHeader "eg2" True)
264 :<|> return NoContent
265 :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.found302 [("Location", "testlocation"), ("Set-Cookie", "testcookie=test")] "")
270 :<|> "capture" :> Capture "name" String :> Raw
272 failApi :: Proxy FailApi
275 failServer :: Application
276 failServer = serve failApi (
277 (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.ok200 [] "")
278 :<|> (\ _capture -> Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "application/json")] "")
279 :<|> (Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "fooooo")] "")
282 -- * basic auth stuff
285 BasicAuth "foo-realm" () :> "private" :> "basic" :> Get '[JSON] Person
287 basicAuthAPI :: Proxy BasicAuthAPI
290 basicAuthHandler :: BasicAuthCheck ()
292 let check (BasicAuthData username password) =
293 if username == "servant" && password == "server"
294 then return (Authorized ())
295 else return Unauthorized
296 in BasicAuthCheck check
298 basicServerContext :: Context '[ BasicAuthCheck () ]
299 basicServerContext = basicAuthHandler :. EmptyContext
301 basicAuthServer :: Application
302 basicAuthServer = serveWithContext basicAuthAPI basicServerContext (const (return alice))
304 -- * general auth stuff
307 AuthProtect "auth-tag" :> "private" :> "auth" :> Get '[JSON] Person
309 genAuthAPI :: Proxy GenAuthAPI
312 type instance AuthServerData (AuthProtect "auth-tag") = ()
313 type instance Auth.AuthClientData (AuthProtect "auth-tag") = ()
315 genAuthHandler :: AuthHandler Wai.Request ()
317 let handler req = case lookup "AuthHeader" (Wai.requestHeaders req) of
318 Nothing -> throwError (err401 { errBody = "Missing auth header" })
320 in mkAuthHandler handler
322 genAuthServerContext :: Context '[ AuthHandler Wai.Request () ]
323 genAuthServerContext = genAuthHandler :. EmptyContext
325 genAuthServer :: Application
326 genAuthServer = serveWithContext genAuthAPI genAuthServerContext (const (return alice))
328 {-# NOINLINE manager' #-}
329 manager' :: Client.Manager
330 manager' = unsafePerformIO $ Client.newManager Client.defaultManagerSettings
332 runClient :: BaseUrl -> ClientM a -> IO (Either ClientError a)
333 runClient x baseUrl' = runClientM x $ mkClientEnv manager' baseUrl'
336 sucessSpec = beforeAll (runTestServer server) $ afterAll killTestServer $ do
337 it "Servant.API.Get root" $ \(_, baseUrl) -> do
338 left show <$> runClient getRoot baseUrl `shouldReturn` Right bob
340 it "Servant.API.Get" $ \(_, baseUrl) -> do
341 left show <$> runClient getGet baseUrl `shouldReturn` Right alice
343 describe "Servant.API.Delete" $ do
344 it "allows empty content type" $ \(_, baseUrl) -> do
345 left show <$> runClient getDeleteEmpty baseUrl `shouldReturn` Right NoContent
347 it "allows content type" $ \(_, baseUrl) -> do
348 left show <$> runClient getDeleteContentType baseUrl `shouldReturn` Right NoContent
350 it "Servant.API.Capture" $ \(_, baseUrl) -> do
351 left show <$> runClient (getCapture "Paula") baseUrl `shouldReturn` Right (Person "Paula" 0)
353 it "Servant.API.CaptureAll" $ \(_, baseUrl) -> do
354 let expected = [(Person "Paula" 0), (Person "Peta" 1)]
355 left show <$> runClient (getCaptureAll ["Paula", "Peta"]) baseUrl `shouldReturn` Right expected
357 it "Servant.API.ReqBody" $ \(_, baseUrl) -> do
358 let p = Person "Clara" 42
359 left show <$> runClient (getBody p) baseUrl `shouldReturn` Right p
361 it "Servant.API FailureResponse" $ \(_, baseUrl) -> do
362 left show <$> runClient (getQueryParam (Just "alice")) baseUrl `shouldReturn` Right alice
363 Left (FailureResponse req _) <- runClient (getQueryParam (Just "bob")) baseUrl
364 Req.requestPath req `shouldBe` (baseUrl, "/param")
365 toList (Req.requestQueryString req) `shouldBe` [("name", Just "bob")]
366 Req.requestMethod req `shouldBe` HTTP.methodGet
368 it "Servant.API.QueryParam" $ \(_, baseUrl) -> do
369 left show <$> runClient (getQueryParam (Just "alice")) baseUrl `shouldReturn` Right alice
370 Left (FailureResponse _ r) <- runClient (getQueryParam (Just "bob")) baseUrl
371 responseStatusCode r `shouldBe` HTTP.Status 400 "bob not found"
373 it "Servant.API.QueryParam.QueryParams" $ \(_, baseUrl) -> do
374 left show <$> runClient (getQueryParams []) baseUrl `shouldReturn` Right []
375 left show <$> runClient (getQueryParams ["alice", "bob"]) baseUrl
376 `shouldReturn` Right [Person "alice" 0, Person "bob" 1]
378 context "Servant.API.QueryParam.QueryFlag" $
379 forM_ [False, True] $ \ flag -> it (show flag) $ \(_, baseUrl) -> do
380 left show <$> runClient (getQueryFlag flag) baseUrl `shouldReturn` Right flag
382 it "Servant.API.Raw on success" $ \(_, baseUrl) -> do
383 res <- runClient (getRawSuccess HTTP.methodGet) baseUrl
385 Left e -> assertFailure $ show e
387 responseStatusCode r `shouldBe` HTTP.status200
388 responseBody r `shouldBe` "rawSuccess"
390 it "Servant.API.Raw should return a Left in case of failure" $ \(_, baseUrl) -> do
391 res <- runClient (getRawFailure HTTP.methodGet) baseUrl
393 Right _ -> assertFailure "expected Left, but got Right"
394 Left (FailureResponse _ r) -> do
395 responseStatusCode r `shouldBe` HTTP.status400
396 responseBody r `shouldBe` "rawFailure"
397 Left e -> assertFailure $ "expected FailureResponse, but got " ++ show e
399 it "Returns headers appropriately" $ \(_, baseUrl) -> do
400 res <- runClient getRespHeaders baseUrl
402 Left e -> assertFailure $ show e
403 Right val -> getHeaders val `shouldBe` [("X-Example1", "1729"), ("X-Example2", "eg2")]
405 it "Stores Cookie in CookieJar after a redirect" $ \(_, baseUrl) -> do
406 mgr <- Client.newManager Client.defaultManagerSettings
407 cj <- atomically . newTVar $ Client.createCookieJar []
408 _ <- runClientM (getRedirectWithCookie HTTP.methodGet) (ClientEnv mgr baseUrl (Just cj))
409 cookie <- listToMaybe . Client.destroyCookieJar <$> atomically (readTVar cj)
410 Client.cookie_name <$> cookie `shouldBe` Just "testcookie"
411 Client.cookie_value <$> cookie `shouldBe` Just "test"
413 modifyMaxSuccess (const 20) $ do
414 it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ \(_, baseUrl) ->
415 property $ forAllShrink pathGen shrink $ \(NonEmpty cap) num flag body ->
417 result <- left show <$> runClient (getMultiple cap num flag body) baseUrl
419 result === Right (cap, num, flag, body)
422 wrappedApiSpec :: Spec
423 wrappedApiSpec = describe "error status codes" $ do
424 let serveW api = serve api $ throwError $ ServerError 500 "error message" "" []
425 context "are correctly handled by the client" $
426 let test :: (WrappedApi, String) -> Spec
427 test (WrappedApi api, desc) =
428 it desc $ bracket (runTestServer $ serveW api) killTestServer $ \(_, baseUrl) -> do
429 let getResponse :: ClientM ()
430 getResponse = client api
431 Left (FailureResponse _ r) <- runClient getResponse baseUrl
432 responseStatusCode r `shouldBe` (HTTP.Status 500 "error message")
434 (WrappedApi (Proxy :: Proxy (Delete '[JSON] ())), "Delete") :
435 (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Get") :
436 (WrappedApi (Proxy :: Proxy (Post '[JSON] ())), "Post") :
437 (WrappedApi (Proxy :: Proxy (Put '[JSON] ())), "Put") :
441 failSpec = beforeAll (runTestServer failServer) $ afterAll killTestServer $ do
443 context "client returns errors appropriately" $ do
444 it "reports FailureResponse" $ \(_, baseUrl) -> do
445 let (_ :<|> _ :<|> getDeleteEmpty :<|> _) = client api
446 Left res <- runClient getDeleteEmpty baseUrl
448 FailureResponse _ r | responseStatusCode r == HTTP.status404 -> return ()
449 _ -> fail $ "expected 404 response, but got " <> show res
451 it "reports DecodeFailure" $ \(_, baseUrl) -> do
452 let (_ :<|> _ :<|> _ :<|> getCapture :<|> _) = client api
453 Left res <- runClient (getCapture "foo") baseUrl
455 DecodeFailure _ _ -> return ()
456 _ -> fail $ "expected DecodeFailure, but got " <> show res
458 it "reports ConnectionError" $ \_ -> do
459 let (getGetWrongHost :<|> _) = client api
460 Left res <- runClient getGetWrongHost (BaseUrl Http "127.0.0.1" 19872 "")
462 ConnectionError _ -> return ()
463 _ -> fail $ "expected ConnectionError, but got " <> show res
465 it "reports UnsupportedContentType" $ \(_, baseUrl) -> do
466 let (_ :<|> getGet :<|> _ ) = client api
467 Left res <- runClient getGet baseUrl
469 UnsupportedContentType ("application/octet-stream") _ -> return ()
470 _ -> fail $ "expected UnsupportedContentType, but got " <> show res
472 it "reports InvalidContentTypeHeader" $ \(_, baseUrl) -> do
473 let (_ :<|> _ :<|> _ :<|> _ :<|> _ :<|> getBody :<|> _) = client api
474 Left res <- runClient (getBody alice) baseUrl
476 InvalidContentTypeHeader _ -> return ()
477 _ -> fail $ "expected InvalidContentTypeHeader, but got " <> show res
479 data WrappedApi where
480 WrappedApi :: (HasServer (api :: *) '[], Server api ~ Handler a,
481 HasClient ClientM api, Client ClientM api ~ ClientM ()) =>
482 Proxy api -> WrappedApi
484 basicAuthSpec :: Spec
485 basicAuthSpec = beforeAll (runTestServer basicAuthServer) $ afterAll killTestServer $ do
486 context "Authentication works when requests are properly authenticated" $ do
488 it "Authenticates a BasicAuth protected server appropriately" $ \(_,baseUrl) -> do
489 let getBasic = client basicAuthAPI
490 let basicAuthData = BasicAuthData "servant" "server"
491 left show <$> runClient (getBasic basicAuthData) baseUrl `shouldReturn` Right alice
493 context "Authentication is rejected when requests are not authenticated properly" $ do
495 it "Authenticates a BasicAuth protected server appropriately" $ \(_,baseUrl) -> do
496 let getBasic = client basicAuthAPI
497 let basicAuthData = BasicAuthData "not" "password"
498 Left (FailureResponse _ r) <- runClient (getBasic basicAuthData) baseUrl
499 responseStatusCode r `shouldBe` HTTP.Status 403 "Forbidden"
502 genAuthSpec = beforeAll (runTestServer genAuthServer) $ afterAll killTestServer $ do
503 context "Authentication works when requests are properly authenticated" $ do
505 it "Authenticates a AuthProtect protected server appropriately" $ \(_, baseUrl) -> do
506 let getProtected = client genAuthAPI
507 let authRequest = Auth.mkAuthenticatedRequest () (\_ req -> Req.addHeader "AuthHeader" ("cool" :: String) req)
508 left show <$> runClient (getProtected authRequest) baseUrl `shouldReturn` Right alice
510 context "Authentication is rejected when requests are not authenticated properly" $ do
512 it "Authenticates a AuthProtect protected server appropriately" $ \(_, baseUrl) -> do
513 let getProtected = client genAuthAPI
514 let authRequest = Auth.mkAuthenticatedRequest () (\_ req -> Req.addHeader "Wrong" ("header" :: String) req)
515 Left (FailureResponse _ r) <- runClient (getProtected authRequest) baseUrl
516 responseStatusCode r `shouldBe` (HTTP.Status 401 "Unauthorized")
520 type HoistClientAPI = Get '[JSON] Int :<|> Capture "n" Int :> Post '[JSON] Int
522 hoistClientAPI :: Proxy HoistClientAPI
523 hoistClientAPI = Proxy
525 hoistClientServer :: Application -- implements HoistClientAPI
526 hoistClientServer = serve hoistClientAPI $ return 5 :<|> (\n -> return n)
528 hoistClientSpec :: Spec
529 hoistClientSpec = beforeAll (runTestServer hoistClientServer) $ afterAll killTestServer $ do
530 describe "Servant.Client.hoistClient" $ do
531 it "allows us to GET/POST/... requests in IO instead of ClientM" $ \(_, baseUrl) -> do
532 let (getInt :<|> postInt)
533 = hoistClient hoistClientAPI
534 (fmap (either (error . show) id) . flip runClient baseUrl)
535 (client hoistClientAPI)
537 getInt `shouldReturn` 5
538 postInt 5 `shouldReturn` 5
541 type ConnectionErrorAPI = Get '[JSON] Int
543 connectionErrorAPI :: Proxy ConnectionErrorAPI
544 connectionErrorAPI = Proxy
546 connectionErrorSpec :: Spec
547 connectionErrorSpec = describe "Servant.Client.ClientError" $
548 it "correctly catches ConnectionErrors when the HTTP request can't go through" $ do
549 let getInt = client connectionErrorAPI
550 let baseUrl' = BaseUrl Http "example.invalid" 80 ""
551 let isHttpError (Left (ConnectionError e)) = isJust $ fromException @Client.HttpException e
552 isHttpError _ = False
553 (isHttpError <$> runClient getInt baseUrl') `shouldReturn` True
556 pathGen :: Gen (NonEmptyList Char)
557 pathGen = fmap NonEmpty path
559 path = listOf1 $ elements $
560 filter (not . (`elem` ("?%[]/#;" :: String))) $
565 -- * Type 'TestServer'
566 data TestServer = TestServer
568 , socket :: Net.Socket
572 runTestServer :: Wai.Application -> IO TestServer
573 runTestServer waiApp = do
574 let baseURI = fromJust $ parseURI "http://localhost:8080"
575 (port, socket) <- openTestSocket
577 Warp.runSettingsSocket
578 (Warp.setPort port $ Warp.defaultSettings)
580 manager <- Client.newManager Client.defaultManagerSettings
582 { env = clientEnv manager baseURI
585 killTestServer :: TestServer -> IO ()
586 killTestServer TestServer{..} = do
590 openTestSocket :: IO (Warp.Port, Net.Socket)
592 let host = Net.tupleToHostAddress (127, 0, 0, 1)
594 sock <- Net.socket Net.AF_INET Net.Stream Net.defaultProtocol
595 Net.setSocketOption sock Net.ReuseAddr 1
596 Net.bind sock (Net.SockAddrInet port host)