2{-# LANGUAGE ConstraintKinds #-}
3{-# LANGUAGE DataKinds #-}
4{-# LANGUAGE DeriveGeneric #-}
5{-# LANGUAGE FlexibleContexts #-}
6{-# LANGUAGE FlexibleInstances #-}
7{-# LANGUAGE FunctionalDependencies #-}
9{-# LANGUAGE MultiParamTypeClasses #-}
10{-# LANGUAGE OverloadedStrings #-}
11{-# LANGUAGE PolyKinds #-}
12{-# LANGUAGE RecordWildCards #-}
13{-# LANGUAGE ScopedTypeVariables #-}
14{-# LANGUAGE StandaloneDeriving #-}
15{-# LANGUAGE TypeApplications #-}
16{-# LANGUAGE TypeFamilies #-}
17{-# LANGUAGE TypeOperators #-}
18{-# LANGUAGE UndecidableInstances #-}
19{-# OPTIONS_GHC -freduction-depth=100 #-}
20{-# OPTIONS_GHC -fno-warn-orphans #-}
21{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
23module Servant.ClientSpec (spec, Person(..), startWaiApp, endWaiApp) where
30import Control.Concurrent
31 (ThreadId, forkIO, killThread)
34import Control.Exception
35 (bracket, fromException, IOException)
36import Control.Monad.Error.Class
49import qualified Network.HTTP.Types as HTTP
51import qualified Network.Wai as Wai
52import Network.Wai.Handler.Warp
54import Test.Hspec.QuickCheck
57import Web.FormUrlEncoded
61 ((:<|>) ((:<|>)), (:>), AuthProtect, BasicAuth,
62 BasicAuthData (..), Capture, CaptureAll, Delete,
63 DeleteNoContent, EmptyAPI, FormUrlEncoded, Get, Header,
64 Headers, JSON, NoContent (NoContent), Post, Put, QueryFlag,
65 QueryParam, QueryParams, Raw, ReqBody, addHeader, getHeaders)
66import qualified Servant.Client.Core.Auth as Auth
67import qualified Servant.Client.Core.Request as Req
68import Servant.HttpStreams
70import Servant.Server.Experimental.Auth
71import Servant.Test.ComprehensiveAPI
73-- This declaration simply checks that all instances are in place.
74_ = client comprehensiveAPIWithoutStreaming
77spec = describe "Servant.HttpStreams" $ do
91 } deriving (Eq, Show, Generic)
93instance NFData Person where
94 rnf (Person n a) = rnf n `seq` rnf a
97instance FromJSON Person
100instance FromForm Person
102instance Arbitrary Person where
103 arbitrary = Person <$> arbitrary <*> arbitrary
106alice = Person "Alice" 42
109carol = Person "Carol" 17
111type TestHeaders = '[Header "X-Example1" Int, Header "X-Example2" String]
115 :<|> "get" :> Get '[JSON] Person
116 :<|> "deleteEmpty" :> DeleteNoContent
117 :<|> "capture" :> Capture "name" String :> Get '[JSON,FormUrlEncoded] Person
118 :<|> "captureAll" :> CaptureAll "names" String :> Get '[JSON] [Person]
119 :<|> "body" :> ReqBody '[FormUrlEncoded,JSON] Person :> Post '[JSON] Person
120 :<|> "param" :> QueryParam "name" String :> Get '[FormUrlEncoded,JSON] Person
121 :<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person]
122 :<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool
123 :<|> "rawSuccess" :> Raw
124 :<|> "rawFailure" :> Raw
126 Capture "first" String :>
127 QueryParam "second" Int :>
129 ReqBody '[JSON] [(String, [Rational])] :>
130 Get '[JSON] (String, Maybe Int, Bool, [(String, [Rational])])
131 :<|> "headers" :> Get '[JSON] (Headers TestHeaders Bool)
132 :<|> "deleteContentType" :> DeleteNoContent
133 :<|> "redirectWithCookie" :> Raw
134 :<|> "empty" :> EmptyAPI
139getRoot :: ClientM Person
140getGet :: ClientM Person
141getDeleteEmpty :: ClientM NoContent
142getCapture :: String -> ClientM Person
143getCaptureAll :: [String] -> ClientM [Person]
144getBody :: Person -> ClientM Person
145getQueryParam :: Maybe String -> ClientM Person
146getQueryParams :: [String] -> ClientM [Person]
147getQueryFlag :: Bool -> ClientM Bool
148getRawSuccess :: HTTP.Method -> ClientM Response
149getRawFailure :: HTTP.Method -> ClientM Response
150getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])]
151 -> ClientM (String, Maybe Int, Bool, [(String, [Rational])])
152getRespHeaders :: ClientM (Headers TestHeaders Bool)
153getDeleteContentType :: ClientM NoContent
154_getRedirectWithCookie :: HTTP.Method -> ClientM Response
169 :<|> getDeleteContentType
170 :<|> _getRedirectWithCookie
171 :<|> EmptyClient = client api
177 :<|> return NoContent
178 :<|> (\ name -> return $ Person name 0)
179 :<|> (\ names -> return (zipWith Person names [0..]))
181 :<|> (\ name -> case name of
182 Just "alice" -> return alice
183 Just n -> throwError $ ServerError 400 (n ++ " not found") "" []
184 Nothing -> throwError $ ServerError 400 "missing parameter" "" [])
185 :<|> (\ names -> return (zipWith Person names [0..]))
187 :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.ok200 [] "rawSuccess")
188 :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.badRequest400 [] "rawFailure")
189 :<|> (\ a b c d -> return (a, b, c, d))
190 :<|> (return $ addHeader 1729 $ addHeader "eg2" True)
191 :<|> return NoContent
192 :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.found302 [("Location", "testlocation"), ("Set-Cookie", "testcookie=test")] "")
197 :<|> "capture" :> Capture "name" String :> Raw
199failApi :: Proxy FailApi
202failServer :: Application
203failServer = serve failApi (
204 (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.ok200 [] "")
205 :<|> (\ _capture -> Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "application/json")] "")
206 :<|> (Tagged $ \_request respond -> respond $ Wai.responseLBS HTTP.ok200 [("content-type", "fooooo")] "")
212 BasicAuth "foo-realm" () :> "private" :> "basic" :> Get '[JSON] Person
214basicAuthAPI :: Proxy BasicAuthAPI
217basicAuthHandler :: BasicAuthCheck ()
219 let check (BasicAuthData username password) =
220 if username == "servant" && password == "server"
221 then return (Authorized ())
222 else return Unauthorized
223 in BasicAuthCheck check
225basicServerContext :: Context '[ BasicAuthCheck () ]
226basicServerContext = basicAuthHandler :. EmptyContext
228basicAuthServer :: Application
229basicAuthServer = serveWithContext basicAuthAPI basicServerContext (const (return alice))
231-- * general auth stuff
234 AuthProtect "auth-tag" :> "private" :> "auth" :> Get '[JSON] Person
236genAuthAPI :: Proxy GenAuthAPI
239type instance AuthServerData (AuthProtect "auth-tag") = ()
240type instance Auth.AuthClientData (AuthProtect "auth-tag") = ()
242genAuthHandler :: AuthHandler Wai.Request ()
244 let handler req = case lookup "AuthHeader" (Wai.requestHeaders req) of
245 Nothing -> throwError (err401 { errBody = "Missing auth header" })
247 in mkAuthHandler handler
249genAuthServerContext :: Context '[ AuthHandler Wai.Request () ]
250genAuthServerContext = genAuthHandler :. EmptyContext
252genAuthServer :: Application
253genAuthServer = serveWithContext genAuthAPI genAuthServerContext (const (return alice))
255runClient :: NFData a => ClientM a -> BaseUrl -> IO (Either ClientError a)
256runClient x burl = withClientEnvIO burl (runClientM x)
258runClientUnsafe :: ClientM a -> BaseUrl -> IO (Either ClientError a)
259runClientUnsafe x burl = withClientEnvIO burl (runClientMUnsafe x)
261 runClientMUnsafe x env = withClientM x env return
264successSpec = beforeAll (startWaiApp server) $ afterAll endWaiApp $ do
265 it "Servant.API.Get root" $ \(_, baseUrl) -> do
266 left show <$> runClient getRoot baseUrl `shouldReturn` Right carol
268 it "Servant.API.Get" $ \(_, baseUrl) -> do
269 left show <$> runClient getGet baseUrl `shouldReturn` Right alice
271 describe "Servant.API.Delete" $ do
272 it "allows empty content type" $ \(_, baseUrl) -> do
273 left show <$> runClient getDeleteEmpty baseUrl `shouldReturn` Right NoContent
275 it "allows content type" $ \(_, baseUrl) -> do
276 left show <$> runClient getDeleteContentType baseUrl `shouldReturn` Right NoContent
278 it "Servant.API.Capture" $ \(_, baseUrl) -> do
279 left show <$> runClient (getCapture "Paula") baseUrl `shouldReturn` Right (Person "Paula" 0)
281 it "Servant.API.CaptureAll" $ \(_, baseUrl) -> do
282 let expected = [(Person "Paula" 0), (Person "Peta" 1)]
283 left show <$> runClient (getCaptureAll ["Paula", "Peta"]) baseUrl `shouldReturn` Right expected
285 it "Servant.API.ReqBody" $ \(_, baseUrl) -> do
286 let p = Person "Clara" 42
287 left show <$> runClient (getBody p) baseUrl `shouldReturn` Right p
289 it "Servant.API FailureResponse" $ \(_, baseUrl) -> do
290 left show <$> runClient (getQueryParam (Just "alice")) baseUrl `shouldReturn` Right alice
291 Left (FailureResponse req _) <- runClient (getQueryParam (Just "bob")) baseUrl
292 Req.requestPath req `shouldBe` (baseUrl, "/param")
293 toList (Req.requestQueryString req) `shouldBe` [("name", Just "bob")]
294 Req.requestMethod req `shouldBe` HTTP.methodGet
296 it "Servant.API.QueryParam" $ \(_, baseUrl) -> do
297 left show <$> runClient (getQueryParam (Just "alice")) baseUrl `shouldReturn` Right alice
298 Left (FailureResponse _ r) <- runClient (getQueryParam (Just "bob")) baseUrl
299 responseStatusCode r `shouldBe` HTTP.Status 400 "bob not found"
301 it "Servant.API.QueryParam.QueryParams" $ \(_, baseUrl) -> do
302 left show <$> runClient (getQueryParams []) baseUrl `shouldReturn` Right []
303 left show <$> runClient (getQueryParams ["alice", "bob"]) baseUrl
304 `shouldReturn` Right [Person "alice" 0, Person "bob" 1]
306 context "Servant.API.QueryParam.QueryFlag" $
307 forM_ [False, True] $ \ flag -> it (show flag) $ \(_, baseUrl) -> do
308 left show <$> runClient (getQueryFlag flag) baseUrl `shouldReturn` Right flag
310 it "Servant.API.Raw on success" $ \(_, baseUrl) -> do
311 res <- runClient (getRawSuccess HTTP.methodGet) baseUrl
313 Left e -> assertFailure $ show e
315 responseStatusCode r `shouldBe` HTTP.status200
316 responseBody r `shouldBe` "rawSuccess"
318 it "Servant.API.Raw should return a Left in case of failure" $ \(_, baseUrl) -> do
319 res <- runClient (getRawFailure HTTP.methodGet) baseUrl
321 Right _ -> assertFailure "expected Left, but got Right"
322 Left (FailureResponse _ r) -> do
323 responseStatusCode r `shouldBe` HTTP.status400
324 responseBody r `shouldBe` "rawFailure"
325 Left e -> assertFailure $ "expected FailureResponse, but got " ++ show e
327 it "Returns headers appropriately" $ \(_, baseUrl) -> do
328 res <- runClient getRespHeaders baseUrl
330 Left e -> assertFailure $ show e
331 Right val -> getHeaders val `shouldBe` [("X-Example1", "1729"), ("X-Example2", "eg2")]
333 modifyMaxSuccess (const 20) $ do
334 it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ \(_, baseUrl) ->
335 property $ forAllShrink pathGen shrink $ \(NonEmpty cap) num flag body ->
337 result <- left show <$> runClient (getMultiple cap num flag body) baseUrl
339 result === Right (cap, num, flag, body)
342wrappedApiSpec :: Spec
343wrappedApiSpec = describe "error status codes" $ do
344 let serveW api = serve api $ throwError $ ServerError 500 "error message" "" []
345 context "are correctly handled by the client" $
346 let test :: (WrappedApi, String) -> Spec
347 test (WrappedApi api, desc) =
348 it desc $ bracket (startWaiApp $ serveW api) endWaiApp $ \(_, baseUrl) -> do
349 let getResponse :: ClientM ()
350 getResponse = client api
351 Left (FailureResponse _ r) <- runClient getResponse baseUrl
352 responseStatusCode r `shouldBe` (HTTP.Status 500 "error message")
354 (WrappedApi (Proxy :: Proxy (Delete '[JSON] ())), "Delete") :
355 (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Get") :
356 (WrappedApi (Proxy :: Proxy (Post '[JSON] ())), "Post") :
357 (WrappedApi (Proxy :: Proxy (Put '[JSON] ())), "Put") :
361failSpec = beforeAll (startWaiApp failServer) $ afterAll endWaiApp $ do
363 context "client returns errors appropriately" $ do
364 it "reports FailureResponse" $ \(_, baseUrl) -> do
365 let (_ :<|> _ :<|> getDeleteEmpty :<|> _) = client api
366 Left res <- runClient getDeleteEmpty baseUrl
368 FailureResponse _ r | responseStatusCode r == HTTP.status404 -> return ()
369 _ -> fail $ "expected 404 response, but got " <> show res
371 it "reports DecodeFailure" $ \(_, baseUrl) -> do
372 let (_ :<|> _ :<|> _ :<|> getCapture :<|> _) = client api
373 Left res <- runClient (getCapture "foo") baseUrl
375 DecodeFailure _ _ -> return ()
376 _ -> fail $ "expected DecodeFailure, but got " <> show res
378 -- we don't catch IOException's
379 xit "reports ConnectionError" $ \_ -> do
380 let (getGetWrongHost :<|> _) = client api
381 Left res <- runClient getGetWrongHost (BaseUrl Http "127.0.0.1" 19872 "")
383 ConnectionError _ -> return ()
384 _ -> fail $ "expected ConnectionError, but got " <> show res
386 it "reports UnsupportedContentType" $ \(_, baseUrl) -> do
387 let (_ :<|> getGet :<|> _ ) = client api
388 Left res <- runClient getGet baseUrl
390 UnsupportedContentType ("application/octet-stream") _ -> return ()
391 _ -> fail $ "expected UnsupportedContentType, but got " <> show res
393 it "reports InvalidContentTypeHeader" $ \(_, baseUrl) -> do
394 let (_ :<|> _ :<|> _ :<|> _ :<|> _ :<|> getBody :<|> _) = client api
395 Left res <- runClient (getBody alice) baseUrl
397 InvalidContentTypeHeader _ -> return ()
398 _ -> fail $ "expected InvalidContentTypeHeader, but got " <> show res
401 WrappedApi :: (HasServer (api :: *) '[], Server api ~ Handler a,
402 HasClient ClientM api, Client ClientM api ~ ClientM ()) =>
403 Proxy api -> WrappedApi
406basicAuthSpec = beforeAll (startWaiApp basicAuthServer) $ afterAll endWaiApp $ do
407 context "Authentication works when requests are properly authenticated" $ do
409 it "Authenticates a BasicAuth protected server appropriately" $ \(_,baseUrl) -> do
410 let getBasic = client basicAuthAPI
411 let basicAuthData = BasicAuthData "servant" "server"
412 left show <$> runClient (getBasic basicAuthData) baseUrl `shouldReturn` Right alice
414 context "Authentication is rejected when requests are not authenticated properly" $ do
416 it "Authenticates a BasicAuth protected server appropriately" $ \(_,baseUrl) -> do
417 let getBasic = client basicAuthAPI
418 let basicAuthData = BasicAuthData "not" "password"
419 Left (FailureResponse _ r) <- runClient (getBasic basicAuthData) baseUrl
420 responseStatusCode r `shouldBe` HTTP.Status 403 "Forbidden"
423genAuthSpec = beforeAll (startWaiApp genAuthServer) $ afterAll endWaiApp $ do
424 context "Authentication works when requests are properly authenticated" $ do
426 it "Authenticates a AuthProtect protected server appropriately" $ \(_, baseUrl) -> do
427 let getProtected = client genAuthAPI
428 let authRequest = Auth.mkAuthenticatedRequest () (\_ req -> Req.addHeader "AuthHeader" ("cool" :: String) req)
429 left show <$> runClient (getProtected authRequest) baseUrl `shouldReturn` Right alice
431 context "Authentication is rejected when requests are not authenticated properly" $ do
433 it "Authenticates a AuthProtect protected server appropriately" $ \(_, baseUrl) -> do
434 let getProtected = client genAuthAPI
435 let authRequest = Auth.mkAuthenticatedRequest () (\_ req -> Req.addHeader "Wrong" ("header" :: String) req)
436 Left (FailureResponse _ r) <- runClient (getProtected authRequest) baseUrl
437 responseStatusCode r `shouldBe` (HTTP.Status 401 "Unauthorized")
441type HoistClientAPI = Get '[JSON] Int :<|> Capture "n" Int :> Post '[JSON] Int
443hoistClientAPI :: Proxy HoistClientAPI
444hoistClientAPI = Proxy
446hoistClientServer :: Application -- implements HoistClientAPI
447hoistClientServer = serve hoistClientAPI $ return 5 :<|> (\n -> return n)
449hoistClientSpec :: Spec
450hoistClientSpec = beforeAll (startWaiApp hoistClientServer) $ afterAll endWaiApp $ do
451 describe "Servant.Client.hoistClient" $ do
452 it "allows us to GET/POST/... requests in IO instead of ClientM" $ \(_, baseUrl) -> do
453 let (getInt :<|> postInt)
454 = hoistClient hoistClientAPI
455 (fmap (either (error . show) id) . flip runClientUnsafe baseUrl)
456 (client hoistClientAPI)
458 getInt `shouldReturn` 5
459 postInt 5 `shouldReturn` 5
462type ConnectionErrorAPI = Get '[JSON] Int
464connectionErrorAPI :: Proxy ConnectionErrorAPI
465connectionErrorAPI = Proxy
467connectionErrorSpec :: Spec
468connectionErrorSpec = describe "Servant.Client.ClientError" $
469 xit "correctly catches ConnectionErrors when the HTTP request can't go through" $ do
470 let getInt = client connectionErrorAPI
471 let baseUrl' = BaseUrl Http "example.invalid" 80 ""
472 let isHttpError (Left (ConnectionError e)) = isJust $ fromException @IOException e
473 isHttpError _ = False
474 (isHttpError <$> runClient getInt baseUrl') `shouldReturn` True
478startWaiApp :: Application -> IO (ThreadId, BaseUrl)
480 (port, socket) <- openTestSocket
481 let settings = setPort port $ defaultSettings
482 thread <- forkIO $ runSettingsSocket settings socket app
483 return (thread, BaseUrl Http "127.0.0.1" port "")
486endWaiApp :: (ThreadId, BaseUrl) -> IO ()
487endWaiApp (thread, _) = killThread thread
489openTestSocket :: IO (Port, Socket)
491 s <- socket AF_INET Stream defaultProtocol
492 let localhost = tupleToHostAddress (127, 0, 0, 1)
493 bind s (SockAddrInet defaultPort localhost)
496 return (fromIntegral port, s)
498pathGen :: Gen (NonEmptyList Char)
499pathGen = fmap NonEmpty path
501 path = listOf1 $ elements $
502 filter (not . (`elem` ("?%[]/#;" :: String))) $
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list