From d93e4620d472e40a6e2212081a02afee358516f6 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Thu, 27 Nov 2014 18:28:01 +0100 Subject: [PATCH 01/47] first shot at splitting servant into servant, servant-client and servant-docs --- servant-client.cabal | 59 ++++++ src/Servant/Client.hs | 305 +++++++++++++++++++++++++++++ src/Servant/Common/BaseUrl.hs | 55 ++++++ src/Servant/Common/Req.hs | 132 +++++++++++++ test/Servant/ClientSpec.hs | 218 +++++++++++++++++++++ test/Servant/Common/BaseUrlSpec.hs | 69 +++++++ test/Spec.hs | 1 + 7 files changed, 839 insertions(+) create mode 100644 servant-client.cabal create mode 100644 src/Servant/Client.hs create mode 100644 src/Servant/Common/BaseUrl.hs create mode 100644 src/Servant/Common/Req.hs create mode 100644 test/Servant/ClientSpec.hs create mode 100644 test/Servant/Common/BaseUrlSpec.hs create mode 100644 test/Spec.hs diff --git a/servant-client.cabal b/servant-client.cabal new file mode 100644 index 00000000..fdc4b2df --- /dev/null +++ b/servant-client.cabal @@ -0,0 +1,59 @@ +name: servant-client +version: 0.2 +-- synopsis: +-- description: +license: BSD3 +license-file: LICENSE +author: Alp Mestanogullari, Sönke Hahn, Julian K. Arni +maintainer: alpmestan@gmail.com +copyright: 2014 Zalora South East Asia Pte Ltd +category: Web +build-type: Simple +cabal-version: >=1.10 +tested-with: GHC >= 7.8 + +library + exposed-modules: + Servant.Client + Servant.Common.BaseUrl + Servant.Common.Req + build-depends: + base >=4.7 && <5 + , aeson + , attoparsec + , bytestring + , either + , exceptions + , http-client + , http-types + , network-uri >= 2.6 + , safe + , servant + , string-conversions + , text + , transformers + hs-source-dirs: src + default-language: Haskell2010 + ghc-options: -O0 -Wall + +test-suite spec + type: exitcode-stdio-1.0 + ghc-options: + -Wall -fno-warn-name-shadowing -fno-warn-missing-signatures + default-language: Haskell2010 + hs-source-dirs: test + main-is: Spec.hs + build-depends: + base == 4.* + , aeson + , bytestring + , deepseq + , either + , hspec == 2.* + , http-types + , network >= 2.6 + , QuickCheck + , servant + , servant-client + , wai + , warp diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs new file mode 100644 index 00000000..87c9e1d4 --- /dev/null +++ b/src/Servant/Client.hs @@ -0,0 +1,305 @@ +{-# LANGUAGE InstanceSigs #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +-- | This module provides 'client' which can automatically generate +-- querying functions for each endpoint just from the type representing your +-- API. +module Servant.Client + ( client + , HasClient(..) + , module Servant.Common.BaseUrl + ) where + +import Control.Monad.Trans.Either +import Data.Aeson +import Data.ByteString.Lazy (ByteString) +import Data.List +import Data.Proxy +import Data.String.Conversions +import Data.Text (unpack) +import GHC.TypeLits +import Network.HTTP.Types +import Servant.API +import Servant.Common.BaseUrl +import Servant.Common.Req +import Servant.Common.Text + +-- * Accessing APIs as a Client + +-- | 'client' allows you to produce operations to query an API from a client. +-- +-- > type MyApi = "books" :> Get [Book] -- GET /books +-- > :<|> "books" :> ReqBody Book :> Post Book -- POST /books +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getAllBooks :: BaseUrl -> EitherT String IO [Book] +-- > postNewBook :: Book -> BaseUrl -> EitherT String IO Book +-- > (getAllBooks :<|> postNewBook) = client myApi +client :: HasClient layout => Proxy layout -> Client layout +client p = clientWithRoute p defReq + +-- | This class lets us define how each API combinator +-- influences the creation of an HTTP request. Use 'client' +-- directly, this class implements the client-side +-- behavior of each combinator but you don't have to worry about it. +class HasClient layout where + type Client layout :: * + clientWithRoute :: Proxy layout -> Req -> Client layout + +-- | A client querying function for @a ':<|>' b@ will actually hand you +-- one function for querying @a@ and another one for querying @b@, +-- stitching them together with ':<|>', which really is just like a pair. +-- +-- > type MyApi = "books" :> Get [Book] -- GET /books +-- > :<|> "books" :> ReqBody Book :> Post Book -- POST /books +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getAllBooks :: BaseUrl -> EitherT String IO [Book] +-- > postNewBook :: Book -> BaseUrl -> EitherT String IO Book +-- > (getAllBooks :<|> postNewBook) = client myApi +instance (HasClient a, HasClient b) => HasClient (a :<|> b) where + type Client (a :<|> b) = Client a :<|> Client b + clientWithRoute Proxy req = + clientWithRoute (Proxy :: Proxy a) req :<|> + clientWithRoute (Proxy :: Proxy b) req + +-- | If you use a 'Capture' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument of the type specified by your 'Capture'. +-- That function will take care of inserting a textual representation +-- of this value at the right place in the request path. +-- +-- You can control how values for this type are turned into +-- text by specifying a 'ToText' instance for your type. +-- +-- Example: +-- +-- > type MyApi = "books" :> Capture "isbn" Text :> Get Book +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBook :: Text -> BaseUrl -> EitherT String IO Book +-- > getBook = client myApi +-- > -- then you can just use "getBook" to query that endpoint +instance (KnownSymbol capture, ToText a, HasClient sublayout) + => HasClient (Capture capture a :> sublayout) where + + type Client (Capture capture a :> sublayout) = + a -> Client sublayout + + clientWithRoute Proxy req val = + clientWithRoute (Proxy :: Proxy sublayout) $ + appendToPath p req + + where p = unpack (toText val) + +-- | If you have a 'Delete' endpoint in your API, the client +-- side querying function that is created when calling 'client' +-- will just require an argument that specifies the scheme, host +-- and port to send the request to. +instance HasClient Delete where + type Client Delete = BaseUrl -> EitherT String IO () + + clientWithRoute Proxy req host = + performRequestJSON methodDelete req 204 host + +-- | If you have a 'Get' endpoint in your API, the client +-- side querying function that is created when calling 'client' +-- will just require an argument that specifies the scheme, host +-- and port to send the request to. +instance FromJSON result => HasClient (Get result) where + type Client (Get result) = BaseUrl -> EitherT String IO result + clientWithRoute Proxy req host = + performRequestJSON methodGet req 200 host + +-- | If you have a 'Post' endpoint in your API, the client +-- side querying function that is created when calling 'client' +-- will just require an argument that specifies the scheme, host +-- and port to send the request to. +instance FromJSON a => HasClient (Post a) where + type Client (Post a) = BaseUrl -> EitherT String IO a + + clientWithRoute Proxy req uri = + performRequestJSON methodPost req 201 uri + +-- | If you have a 'Put' endpoint in your API, the client +-- side querying function that is created when calling 'client' +-- will just require an argument that specifies the scheme, host +-- and port to send the request to. +instance FromJSON a => HasClient (Put a) where + type Client (Put a) = BaseUrl -> EitherT String IO a + + clientWithRoute Proxy req host = + performRequestJSON methodPut req 200 host + +-- | If you use a 'QueryParam' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument of the type specified by your 'QueryParam', +-- enclosed in Maybe. +-- +-- If you give Nothing, nothing will be added to the query string. +-- +-- If you give a non-'Nothing' value, this function will take care +-- of inserting a textual representation of this value in the query string. +-- +-- You can control how values for your type are turned into +-- text by specifying a 'ToText' instance for your type. +-- +-- Example: +-- +-- > type MyApi = "books" :> QueryParam "author" Text :> Get [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooksBy :: Maybe Text -> BaseUrl -> EitherT String IO [Book] +-- > getBooksBy = client myApi +-- > -- then you can just use "getBooksBy" to query that endpoint. +-- > -- 'getBooksBy Nothing' for all books +-- > -- 'getBooksBy (Just "Isaac Asimov")' to get all books by Isaac Asimov +instance (KnownSymbol sym, ToText a, HasClient sublayout) + => HasClient (QueryParam sym a :> sublayout) where + + type Client (QueryParam sym a :> sublayout) = + Maybe a -> Client sublayout + + -- if mparam = Nothing, we don't add it to the query string + clientWithRoute Proxy req mparam = + clientWithRoute (Proxy :: Proxy sublayout) $ + appendToQueryString pname mparamText req + + where pname = cs pname' + pname' = symbolVal (Proxy :: Proxy sym) + mparamText = fmap toText mparam + +-- | If you use a 'QueryParams' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument, a list of values of the type specified +-- by your 'QueryParams'. +-- +-- If you give an empty list, nothing will be added to the query string. +-- +-- Otherwise, this function will take care +-- of inserting a textual representation of your values in the query string, +-- under the same query string parameter name. +-- +-- You can control how values for your type are turned into +-- text by specifying a 'ToText' instance for your type. +-- +-- Example: +-- +-- > type MyApi = "books" :> QueryParams "authors" Text :> Get [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooksBy :: [Text] -> BaseUrl -> EitherT String IO [Book] +-- > getBooksBy = client myApi +-- > -- then you can just use "getBooksBy" to query that endpoint. +-- > -- 'getBooksBy []' for all books +-- > -- 'getBooksBy ["Isaac Asimov", "Robert A. Heinlein"]' +-- > -- to get all books by Asimov and Heinlein +instance (KnownSymbol sym, ToText a, HasClient sublayout) + => HasClient (QueryParams sym a :> sublayout) where + + type Client (QueryParams sym a :> sublayout) = + [a] -> Client sublayout + + clientWithRoute Proxy req paramlist = + clientWithRoute (Proxy :: Proxy sublayout) $ + foldl' (\ value req' -> appendToQueryString pname req' value) req paramlist' + + where pname = cs pname' + pname' = symbolVal (Proxy :: Proxy sym) + paramlist' = map (Just . toText) paramlist + +-- | If you use a 'QueryFlag' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional 'Bool' argument. +-- +-- If you give 'False', nothing will be added to the query string. +-- +-- Otherwise, this function will insert a value-less query string +-- parameter under the name associated to your 'QueryFlag'. +-- +-- Example: +-- +-- > type MyApi = "books" :> QueryFlag "published" :> Get [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooks :: Bool -> BaseUrl -> EitherT String IO [Book] +-- > getBooks = client myApi +-- > -- then you can just use "getBooks" to query that endpoint. +-- > -- 'getBooksBy False' for all books +-- > -- 'getBooksBy True' to only get _already published_ books +instance (KnownSymbol sym, HasClient sublayout) + => HasClient (QueryFlag sym :> sublayout) where + + type Client (QueryFlag sym :> sublayout) = + Bool -> Client sublayout + + clientWithRoute Proxy req flag = + clientWithRoute (Proxy :: Proxy sublayout) $ + if flag + then appendToQueryString paramname Nothing req + else req + + where paramname = cs $ symbolVal (Proxy :: Proxy sym) + +-- | Pick a 'Method' and specify where the server you want to query is. You get +-- back the status code and the response body as a 'ByteString'. +instance HasClient Raw where + type Client Raw = Method -> BaseUrl -> EitherT String IO (Int, ByteString) + + clientWithRoute :: Proxy Raw -> Req -> Client Raw + clientWithRoute Proxy req httpMethod host = + performRequest httpMethod req (const True) host + +-- | If you use a 'ReqBody' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument of the type specified by your 'ReqBody'. +-- That function will take care of encoding this argument as JSON and +-- of using it as the request body. +-- +-- All you need is for your type to have a 'ToJSON' instance. +-- +-- Example: +-- +-- > type MyApi = "books" :> ReqBody Book :> Post Book +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > addBook :: Book -> BaseUrl -> EitherT String IO Book +-- > addBook = client myApi +-- > -- then you can just use "addBook" to query that endpoint +instance (ToJSON a, HasClient sublayout) + => HasClient (ReqBody a :> sublayout) where + + type Client (ReqBody a :> sublayout) = + a -> Client sublayout + + clientWithRoute Proxy req body = + clientWithRoute (Proxy :: Proxy sublayout) $ + setRQBody (encode body) req + +-- | Make the querying function append @path@ to the request path. +instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where + type Client (path :> sublayout) = Client sublayout + + clientWithRoute Proxy req = + clientWithRoute (Proxy :: Proxy sublayout) $ + appendToPath p req + + where p = symbolVal (Proxy :: Proxy path) + diff --git a/src/Servant/Common/BaseUrl.hs b/src/Servant/Common/BaseUrl.hs new file mode 100644 index 00000000..eae87c42 --- /dev/null +++ b/src/Servant/Common/BaseUrl.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE ViewPatterns #-} +module Servant.Common.BaseUrl where + +import Data.List +import GHC.Generics +import Network.URI +import Safe +import Text.Read + +-- | URI scheme to use +data Scheme = + Http -- ^ http:// + | Https -- ^ https:// + deriving (Show, Eq, Ord, Generic) + +-- | Simple data type to represent the target of HTTP requests +-- for servant's automatically-generated clients. +data BaseUrl = BaseUrl + { baseUrlScheme :: Scheme -- ^ URI scheme to use + , baseUrlHost :: String -- ^ host (eg "haskell.org") + , baseUrlPort :: Int -- ^ port (eg 80) + } deriving (Show, Eq, Ord, Generic) + +showBaseUrl :: BaseUrl -> String +showBaseUrl (BaseUrl urlscheme host port) = + schemeString ++ "//" ++ host ++ portString + where + schemeString = case urlscheme of + Http -> "http:" + Https -> "https:" + portString = case (urlscheme, port) of + (Http, 80) -> "" + (Https, 443) -> "" + _ -> ":" ++ show port + +parseBaseUrl :: String -> Either String BaseUrl +parseBaseUrl s = case parseURI (removeTrailingSlash s) of + -- This is a rather hacky implementation and should be replaced with something + -- implemented in attoparsec (which is already a dependency anyhow (via aeson)). + Just (URI "http:" (Just (URIAuth "" host (':' : (readMaybe -> Just port)))) "" "" "") -> + Right (BaseUrl Http host port) + Just (URI "http:" (Just (URIAuth "" host "")) "" "" "") -> + Right (BaseUrl Http host 80) + Just (URI "https:" (Just (URIAuth "" host (':' : (readMaybe -> Just port)))) "" "" "") -> + Right (BaseUrl Https host port) + Just (URI "https:" (Just (URIAuth "" host "")) "" "" "") -> + Right (BaseUrl Https host 443) + _ -> if "://" `isInfixOf` s + then Left ("invalid base url: " ++ s) + else parseBaseUrl ("http://" ++ s) + where + removeTrailingSlash str = case lastMay str of + Just '/' -> init str + _ -> str diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs new file mode 100644 index 00000000..62c469d8 --- /dev/null +++ b/src/Servant/Common/Req.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +module Servant.Common.Req where + +import Control.Applicative +import Control.Concurrent +import Control.Exception +import Control.Monad +import Control.Monad.Catch (MonadThrow) +import Control.Monad.IO.Class +import Control.Monad.Trans.Either +import Data.Aeson +import Data.Aeson.Parser +import Data.Aeson.Types +import Data.Attoparsec.ByteString +import Data.ByteString.Lazy +import Data.String.Conversions +import Data.Text +import Network.HTTP.Client +import Network.HTTP.Types +import Network.URI +import Servant.Common.BaseUrl +import System.IO.Unsafe + +import qualified Network.HTTP.Client as Client + +data Req = Req + { reqPath :: String + , qs :: QueryText + , reqBody :: ByteString + } + +defReq :: Req +defReq = Req "" [] "" + +appendToPath :: String -> Req -> Req +appendToPath p req = + req { reqPath = reqPath req ++ "/" ++ p } + +appendToQueryString :: Text -- ^ param name + -> Maybe Text -- ^ param value + -> Req + -> Req +appendToQueryString pname pvalue req = + req { qs = qs req ++ [(pname, pvalue)] + } + +setRQBody :: ByteString -> Req -> Req +setRQBody b req = req { reqBody = b } + +reqToRequest :: (Functor m, MonadThrow m) => Req -> BaseUrl -> m Request +reqToRequest req (BaseUrl reqScheme reqHost reqPort) = fmap (setrqb . setQS ) $ parseUrl url + + where url = show $ nullURI { uriScheme = case reqScheme of + Http -> "http:" + Https -> "https:" + , uriAuthority = Just $ + URIAuth { uriUserInfo = "" + , uriRegName = reqHost + , uriPort = ":" ++ show reqPort + } + , uriPath = reqPath req + } + + setrqb r = r { requestBody = RequestBodyLBS (reqBody req) } + setQS = setQueryString $ queryTextToQuery (qs req) + + +-- * performing requests + +{-# NOINLINE __manager #-} +__manager :: MVar Manager +__manager = unsafePerformIO (newManager defaultManagerSettings >>= newMVar) + +__withGlobalManager :: (Manager -> IO a) -> IO a +__withGlobalManager action = modifyMVar __manager $ \ manager -> do + result <- action manager + return (manager, result) + + +displayHttpRequest :: Method -> String +displayHttpRequest httpmethod = "HTTP " ++ cs httpmethod ++ " request" + + +performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT String IO (Int, ByteString) +performRequest reqMethod req isWantedStatus reqHost = do + partialRequest <- liftIO $ reqToRequest req reqHost + + let request = partialRequest { Client.method = reqMethod + , checkStatus = \ _status _headers _cookies -> Nothing + } + + eResponse <- liftIO $ __withGlobalManager $ \ manager -> + catchStatusCodeException $ + Client.httpLbs request manager + case eResponse of + Left status -> + left (displayHttpRequest reqMethod ++ " failed with status: " ++ showStatus status) + + Right response -> do + let status = Client.responseStatus response + unless (isWantedStatus (statusCode status)) $ + left (displayHttpRequest reqMethod ++ " failed with status: " ++ showStatus status) + return $ (statusCode status, Client.responseBody response) + where + showStatus (Status code message) = + show code ++ " - " ++ cs message + + +performRequestJSON :: FromJSON result => + Method -> Req -> Int -> BaseUrl -> EitherT String IO result +performRequestJSON reqMethod req wantedStatus reqHost = do + (_status, respBody) <- performRequest reqMethod req (== wantedStatus) reqHost + either + (\ message -> left (displayHttpRequest reqMethod ++ " returned invalid json: " ++ message)) + return + (decodeLenient respBody) + + +catchStatusCodeException :: IO a -> IO (Either Status a) +catchStatusCodeException action = + catch (Right <$> action) $ \e -> + case e of + Client.StatusCodeException status _ _ -> return $ Left status + exc -> throwIO exc + +-- | Like 'Data.Aeson.decode' but allows all JSON values instead of just +-- objects and arrays. +decodeLenient :: FromJSON a => ByteString -> Either String a +decodeLenient input = do + v :: Value <- parseOnly (Data.Aeson.Parser.value <* endOfInput) (cs input) + parseEither parseJSON v diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs new file mode 100644 index 00000000..c1a2c5d6 --- /dev/null +++ b/test/Servant/ClientSpec.hs @@ -0,0 +1,218 @@ +{-# LANGUAGE GADTs #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE ViewPatterns #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE OverloadedStrings #-} + +module Servant.ClientSpec where + +import Control.Concurrent +import Control.Exception +import Control.Monad.Trans.Either +import Data.Aeson +import Data.ByteString.Lazy (ByteString) +import Data.Char +import Data.Foldable (forM_) +import Data.Proxy +import Data.Typeable +import GHC.Generics +import Network.HTTP.Types +import Network.Socket +import Network.Wai +import Network.Wai.Handler.Warp +import Test.Hspec +import Test.Hspec.QuickCheck +import Test.QuickCheck + +import Servant.API +import Servant.Client +import Servant.Server + +-- * test data types + +data Person = Person { + name :: String, + age :: Integer + } + deriving (Eq, Show, Generic) + +instance ToJSON Person +instance FromJSON Person + +alice :: Person +alice = Person "Alice" 42 + +type Api = + "get" :> Get Person + :<|> "capture" :> Capture "name" String :> Get Person + :<|> "body" :> ReqBody Person :> Post Person + :<|> "param" :> QueryParam "name" String :> Get Person + :<|> "params" :> QueryParams "names" String :> Get [Person] + :<|> "flag" :> QueryFlag "flag" :> Get Bool + :<|> "rawSuccess" :> Raw + :<|> "rawFailure" :> Raw + :<|> "multiple" :> + Capture "first" String :> + QueryParam "second" Int :> + QueryFlag "third" :> + ReqBody [(String, [Rational])] :> + Get (String, Maybe Int, Bool, [(String, [Rational])]) +api :: Proxy Api +api = Proxy + +server :: Application +server = serve api ( + return alice + :<|> (\ name -> return $ Person name 0) + :<|> return + :<|> (\ name -> case name of + Just "alice" -> return alice + Just name -> left (400, name ++ " not found") + Nothing -> left (400, "missing parameter")) + :<|> (\ names -> return (zipWith Person names [0..])) + :<|> return + :<|> (\ _request respond -> respond $ responseLBS ok200 [] "rawSuccess") + :<|> (\ _request respond -> respond $ responseLBS badRequest400 [] "rawFailure") + :<|> \ a b c d -> return (a, b, c, d) + ) + +withServer :: (BaseUrl -> IO a) -> IO a +withServer action = withWaiDaemon (return server) action + +getGet :: BaseUrl -> EitherT String IO Person +getCapture :: String -> BaseUrl -> EitherT String IO Person +getBody :: Person -> BaseUrl -> EitherT String IO Person +getQueryParam :: Maybe String -> BaseUrl -> EitherT String IO Person +getQueryParams :: [String] -> BaseUrl -> EitherT String IO [Person] +getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool +getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) +getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) +getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] + -> BaseUrl + -> EitherT String IO (String, Maybe Int, Bool, [(String, [Rational])]) +( getGet + :<|> getCapture + :<|> getBody + :<|> getQueryParam + :<|> getQueryParams + :<|> getQueryFlag + :<|> getRawSuccess + :<|> getRawFailure + :<|> getMultiple) + = client api + +spec :: Spec +spec = do + it "Servant.API.Get" $ withServer $ \ host -> do + runEitherT (getGet host) `shouldReturn` Right alice + + it "Servant.API.Capture" $ withServer $ \ host -> do + runEitherT (getCapture "Paula" host) `shouldReturn` Right (Person "Paula" 0) + + it "Servant.API.ReqBody" $ withServer $ \ host -> do + let p = Person "Clara" 42 + runEitherT (getBody p host) `shouldReturn` Right p + + it "Servant.API.QueryParam" $ withServer $ \ host -> do + runEitherT (getQueryParam (Just "alice") host) `shouldReturn` Right alice + Left result <- runEitherT (getQueryParam (Just "bob") host) + result `shouldContain` "bob not found" + + it "Servant.API.QueryParam.QueryParams" $ withServer $ \ host -> do + runEitherT (getQueryParams [] host) `shouldReturn` Right [] + runEitherT (getQueryParams ["alice", "bob"] host) + `shouldReturn` Right [Person "alice" 0, Person "bob" 1] + + context "Servant.API.QueryParam.QueryFlag" $ + forM_ [False, True] $ \ flag -> + it (show flag) $ withServer $ \ host -> do + runEitherT (getQueryFlag flag host) `shouldReturn` Right flag + + it "Servant.API.Raw on success" $ withServer $ \ host -> do + runEitherT (getRawSuccess methodGet host) `shouldReturn` Right (200, "rawSuccess") + + it "Servant.API.Raw on failure" $ withServer $ \ host -> do + runEitherT (getRawFailure methodGet host) `shouldReturn` Right (400, "rawFailure") + + modifyMaxSuccess (const 20) $ do + it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ + property $ forAllShrink pathGen shrink $ \ a -> \ b c d -> + ioProperty $ do + withServer $ \ host -> do + result <- runEitherT (getMultiple a b c d host) + return $ + result === Right (a, b, c, d) + + + context "client correctly handles error status codes" $ do + let test :: WrappedApi -> Spec + test (WrappedApi api) = + it (show (typeOf api)) $ + withWaiDaemon (return (serve api (left (500, "error message")))) $ + \ host -> do + let getResponse :: BaseUrl -> EitherT String IO () + getResponse = client api + Left result <- runEitherT (getResponse host) + result `shouldContain` "error message" + mapM_ test $ + (WrappedApi (Proxy :: Proxy Delete)) : + (WrappedApi (Proxy :: Proxy (Get ()))) : + (WrappedApi (Proxy :: Proxy (Post ()))) : + (WrappedApi (Proxy :: Proxy (Put ()))) : + [] + +data WrappedApi where + WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, + HasClient api, Client api ~ (BaseUrl -> EitherT String IO ()), + Typeable api) => + Proxy api -> WrappedApi + + +-- * utils + +withWaiDaemon :: IO Application -> (BaseUrl -> IO a) -> IO a +withWaiDaemon mkApplication action = do + application <- mkApplication + bracket (acquire application) free (\ (_, _, baseUrl) -> action baseUrl) + where + acquire application = do + (notifyStart, waitForStart) <- lvar + (notifyKilled, waitForKilled) <- lvar + thread <- forkIO $ (do + (krakenPort, socket) <- openTestSocket + let settings = + setPort krakenPort $ -- set here just for consistency, shouldn't be + -- used (it's set in the socket) + setBeforeMainLoop (notifyStart krakenPort) + defaultSettings + runSettingsSocket settings socket application) + `finally` notifyKilled () + krakenPort <- waitForStart + let baseUrl = (BaseUrl Http "localhost" 80){baseUrlPort = krakenPort} + return (thread, waitForKilled, baseUrl) + free (thread, waitForKilled, _) = do + killThread thread + waitForKilled + + lvar :: IO (a -> IO (), IO a) + lvar = do + mvar <- newEmptyMVar + let put = putMVar mvar + wait = readMVar mvar + return (put, wait) + +openTestSocket :: IO (Port, Socket) +openTestSocket = do + s <- socket AF_INET Stream defaultProtocol + localhost <- inet_addr "127.0.0.1" + bind s (SockAddrInet aNY_PORT localhost) + listen s 1 + port <- socketPort s + return (fromIntegral port, s) + +pathGen :: Gen String +pathGen = listOf $ elements $ + filter (not . (`elem` "?%[]/#")) $ + filter isPrint $ + map chr [0..127] diff --git a/test/Servant/Common/BaseUrlSpec.hs b/test/Servant/Common/BaseUrlSpec.hs new file mode 100644 index 00000000..5eef61dc --- /dev/null +++ b/test/Servant/Common/BaseUrlSpec.hs @@ -0,0 +1,69 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} +module Servant.Common.BaseUrlSpec where + +import Control.Applicative +import Control.DeepSeq +import Test.Hspec +import Test.QuickCheck + +import Servant.Common.BaseUrl + +spec :: Spec +spec = do + describe "showBaseUrl" $ do + it "shows a BaseUrl" $ do + showBaseUrl (BaseUrl Http "foo.com" 80) `shouldBe` "http://foo.com" + + it "shows a https BaseUrl" $ do + showBaseUrl (BaseUrl Https "foo.com" 443) `shouldBe` "https://foo.com" + + describe "httpBaseUrl" $ do + it "allows to construct default http BaseUrls" $ do + BaseUrl Http "bar" 80 `shouldBe` BaseUrl Http "bar" 80 + + describe "parseBaseUrl" $ do + it "is total" $ do + property $ \ string -> + deepseq (fmap show (parseBaseUrl string)) True + + it "is the inverse of showBaseUrl" $ do + property $ \ baseUrl -> + counterexample (showBaseUrl baseUrl) $ + parseBaseUrl (showBaseUrl baseUrl) === + Right baseUrl + + it "allows trailing slashes" $ do + parseBaseUrl "foo.com/" `shouldBe` Right (BaseUrl Http "foo.com" 80) + + context "urls without scheme" $ do + it "assumes http" $ do + parseBaseUrl "foo.com" `shouldBe` Right (BaseUrl Http "foo.com" 80) + + it "allows port numbers" $ do + parseBaseUrl "foo.com:8080" `shouldBe` Right (BaseUrl Http "foo.com" 8080) + + it "rejects ftp urls" $ do + parseBaseUrl "ftp://foo.com" `shouldSatisfy` isLeft + +instance Arbitrary BaseUrl where + arbitrary = BaseUrl <$> + elements [Http, Https] <*> + hostNameGen <*> + portGen + where + -- this does not perfectly mirror the url standard, but I hope it's good + -- enough. + hostNameGen = do + let letters = ['a' .. 'z'] ++ ['A' .. 'Z'] + first <- elements letters + middle <- listOf1 $ elements (letters ++ ['0' .. '9'] ++ ['.', '-']) + last <- elements letters + return (first : middle ++ [last]) + portGen = frequency $ + (1, return 80) : + (1, return 443) : + (1, choose (1, 20000)) : + [] + +isLeft :: Either a b -> Bool +isLeft = either (const True) (const False) diff --git a/test/Spec.hs b/test/Spec.hs new file mode 100644 index 00000000..a824f8c3 --- /dev/null +++ b/test/Spec.hs @@ -0,0 +1 @@ +{-# OPTIONS_GHC -F -pgmF hspec-discover #-} From 530797eb42b9664ddd3c26a0a3416ec7c73b80c2 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Mon, 1 Dec 2014 13:41:12 +0100 Subject: [PATCH 02/47] clarify haddocks for HasClient --- src/Servant/Client.hs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 87c9e1d4..8b4f5628 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -44,9 +44,8 @@ client :: HasClient layout => Proxy layout -> Client layout client p = clientWithRoute p defReq -- | This class lets us define how each API combinator --- influences the creation of an HTTP request. Use 'client' --- directly, this class implements the client-side --- behavior of each combinator but you don't have to worry about it. +-- influences the creation of an HTTP request. It's mostly +-- an internal class, you can just use 'client'. class HasClient layout where type Client layout :: * clientWithRoute :: Proxy layout -> Req -> Client layout From 2991161b87468fb6533bfdc06049c93f1838a864 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Mon, 1 Dec 2014 16:29:42 +0100 Subject: [PATCH 03/47] fix travis script... --- servant-client.cabal | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index fdc4b2df..dc2289e4 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -28,7 +28,7 @@ library , http-types , network-uri >= 2.6 , safe - , servant + , servant >= 0.2 , string-conversions , text , transformers @@ -53,7 +53,7 @@ test-suite spec , http-types , network >= 2.6 , QuickCheck - , servant + , servant >= 0.2 , servant-client , wai , warp From f8078001e20782446c0042896b7cd893011fc464 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Mon, 1 Dec 2014 16:38:43 +0100 Subject: [PATCH 04/47] add LICENSE files to all projects --- LICENSE | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..bfee8018 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2014, Zalora South East Asia Pte Ltd + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Zalora South East Asia Pte Ltd nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 6d9fa0f86a291e57ff8d3c33a2d4c7a617d8e561 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Tue, 2 Dec 2014 17:25:38 +0100 Subject: [PATCH 05/47] add travis file --- .travis.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..03aca3f5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: haskell + +notifications: + irc: + channels: + - "irc.freenode.org#servant" + template: + - "%{repository}#%{build_number} - %{commit} on %{branch} by %{author}: %{message}" + - "Build details: %{build_url} - Change view: %{compare_url}" + skip_join: true + on_success: change + on_failure: always From 8c7cc95b8cc3f43d6d62e93513e4585a1d0ad73b Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Tue, 2 Dec 2014 17:48:21 +0100 Subject: [PATCH 06/47] travis: build with ghc 7.8 and clone servant from git before building --- .travis.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.travis.yml b/.travis.yml index 03aca3f5..41be6fa3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,13 @@ language: haskell +ghc: + - 7.8 + +before_install: + - git clone https://github.com/haskell-servant/servant.git + - cabal sandbox init + - cabal sandbox add-source servant/ + notifications: irc: channels: From fe3bd998aa8fc523d04f16eaab391c4429f29173 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Mon, 8 Dec 2014 11:10:51 +0100 Subject: [PATCH 07/47] polish up cabal file, add a README --- README.md | 22 ++++++++++++++++++++++ servant-client.cabal | 25 ++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..1315658a --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# servant-client + +[![Build Status](https://secure.travis-ci.org/haskell-servant/servant-client.svg)](http://travis-ci.org/haskell-servant/servant-client) + +![servant](https://raw.githubusercontent.com/haskell-servant/servant/master/servant.png) + +This library lets you derive automatically Haskell functions that let you query each endpoint of a *servant* webservice. + +## Example + +``` haskell +type MyApi = "books" :> Get [Book] -- GET /books + :<|> "books" :> ReqBody Book :> Post Book -- POST /books + +myApi :: Proxy MyApi +myApi = Proxy + +getAllBooks :: BaseUrl -> EitherT String IO [Book] +postNewBook :: Book -> BaseUrl -> EitherT String IO Book +-- 'client' allows you to produce operations to query an API from a client. +(getAllBooks :<|> postNewBook) = client myApi +``` \ No newline at end of file diff --git a/servant-client.cabal b/servant-client.cabal index dc2289e4..fac8899a 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -1,7 +1,21 @@ name: servant-client version: 0.2 --- synopsis: --- description: +synopsis: automatical derivation of haskell functions that let you query servant webservices +description: + This library lets you derive automatically Haskell functions that + let you query each endpoint of a webservice. + . + Example below. + . + > type MyApi = "books" :> Get [Book] -- GET /books + > :<|> "books" :> ReqBody Book :> Post Book -- POST /books + > + > myApi :: Proxy MyApi + > myApi = Proxy + > + > getAllBooks :: BaseUrl -> EitherT String IO [Book] + > postNewBook :: Book -> BaseUrl -> EitherT String IO Book + > (getAllBooks :<|> postNewBook) = client myApi license: BSD3 license-file: LICENSE author: Alp Mestanogullari, Sönke Hahn, Julian K. Arni @@ -11,6 +25,11 @@ category: Web build-type: Simple cabal-version: >=1.10 tested-with: GHC >= 7.8 +homepage: http://haskell-servant.github.io/ +Bug-reports: http://github.com/haskell-servant/servant-client/issues +source-repository head + type: git + location: http://github.com/haskell-servant/servant-client.git library exposed-modules: @@ -34,7 +53,7 @@ library , transformers hs-source-dirs: src default-language: Haskell2010 - ghc-options: -O0 -Wall + ghc-options: -Wall test-suite spec type: exitcode-stdio-1.0 From 2ecc6124b0a77c15fafd900116c778bc7880af5d Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Mon, 8 Dec 2014 12:52:30 +0100 Subject: [PATCH 08/47] add HasClient instance for Header --- src/Servant/Client.hs | 49 ++++++++++++++++++++++++++++++++++----- src/Servant/Common/Req.hs | 20 +++++++++++++--- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 8b4f5628..ed63de47 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -21,7 +21,7 @@ import Data.Proxy import Data.String.Conversions import Data.Text (unpack) import GHC.TypeLits -import Network.HTTP.Types +import qualified Network.HTTP.Types as H import Servant.API import Servant.Common.BaseUrl import Servant.Common.Req @@ -108,7 +108,7 @@ instance HasClient Delete where type Client Delete = BaseUrl -> EitherT String IO () clientWithRoute Proxy req host = - performRequestJSON methodDelete req 204 host + performRequestJSON H.methodDelete req 204 host -- | If you have a 'Get' endpoint in your API, the client -- side querying function that is created when calling 'client' @@ -117,7 +117,44 @@ instance HasClient Delete where instance FromJSON result => HasClient (Get result) where type Client (Get result) = BaseUrl -> EitherT String IO result clientWithRoute Proxy req host = - performRequestJSON methodGet req 200 host + performRequestJSON H.methodGet req 200 host + +-- | If you use a 'Header' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument of the type specified by your 'Header', +-- wrapped in Maybe. +-- +-- That function will take care of encoding this argument as Text +-- in the request headers. +-- +-- All you need is for your type to have a 'ToText' instance. +-- +-- Example: +-- +-- > newtype Referer = Referer Text +-- > deriving (Eq, Show, FromText, ToText) +-- > +-- > -- GET /view-my-referer +-- > type MyApi = "view-my-referer" :> Header "Referer" Referer :> Get Referer +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > viewReferer :: Maybe Referer -> BaseUrl -> EitherT String IO Book +-- > viewReferer = client myApi +-- > -- then you can just use "viewRefer" to query that endpoint +-- > -- specifying Nothing or Just "http://haskell.org/" as arguments +instance (KnownSymbol sym, ToText a, HasClient sublayout) + => HasClient (Header sym a :> sublayout) where + + type Client (Header sym a :> sublayout) = + Maybe a -> Client sublayout + + clientWithRoute Proxy req mval = + clientWithRoute (Proxy :: Proxy sublayout) $ + maybe req (\value -> addHeader hname value req) mval + + where hname = symbolVal (Proxy :: Proxy sym) -- | If you have a 'Post' endpoint in your API, the client -- side querying function that is created when calling 'client' @@ -127,7 +164,7 @@ instance FromJSON a => HasClient (Post a) where type Client (Post a) = BaseUrl -> EitherT String IO a clientWithRoute Proxy req uri = - performRequestJSON methodPost req 201 uri + performRequestJSON H.methodPost req 201 uri -- | If you have a 'Put' endpoint in your API, the client -- side querying function that is created when calling 'client' @@ -137,7 +174,7 @@ instance FromJSON a => HasClient (Put a) where type Client (Put a) = BaseUrl -> EitherT String IO a clientWithRoute Proxy req host = - performRequestJSON methodPut req 200 host + performRequestJSON H.methodPut req 200 host -- | If you use a 'QueryParam' in one of your endpoints in your API, -- the corresponding querying function will automatically take @@ -258,7 +295,7 @@ instance (KnownSymbol sym, HasClient sublayout) -- | Pick a 'Method' and specify where the server you want to query is. You get -- back the status code and the response body as a 'ByteString'. instance HasClient Raw where - type Client Raw = Method -> BaseUrl -> EitherT String IO (Int, ByteString) + type Client Raw = H.Method -> BaseUrl -> EitherT String IO (Int, ByteString) clientWithRoute :: Proxy Raw -> Req -> Client Raw clientWithRoute Proxy req httpMethod host = diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 62c469d8..9f7db5c5 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -13,13 +13,16 @@ import Data.Aeson import Data.Aeson.Parser import Data.Aeson.Types import Data.Attoparsec.ByteString -import Data.ByteString.Lazy +import Data.ByteString.Lazy hiding (pack) +import Data.String import Data.String.Conversions import Data.Text +import Data.Text.Encoding import Network.HTTP.Client import Network.HTTP.Types import Network.URI import Servant.Common.BaseUrl +import Servant.Common.Text import System.IO.Unsafe import qualified Network.HTTP.Client as Client @@ -28,10 +31,11 @@ data Req = Req { reqPath :: String , qs :: QueryText , reqBody :: ByteString + , headers :: [(String, Text)] } defReq :: Req -defReq = Req "" [] "" +defReq = Req "" [] "" [] appendToPath :: String -> Req -> Req appendToPath p req = @@ -45,11 +49,17 @@ appendToQueryString pname pvalue req = req { qs = qs req ++ [(pname, pvalue)] } +addHeader :: ToText a => String -> a -> Req -> Req +addHeader name val req = req { headers = headers req + ++ [(name, toText val)] + } + setRQBody :: ByteString -> Req -> Req setRQBody b req = req { reqBody = b } reqToRequest :: (Functor m, MonadThrow m) => Req -> BaseUrl -> m Request -reqToRequest req (BaseUrl reqScheme reqHost reqPort) = fmap (setrqb . setQS ) $ parseUrl url +reqToRequest req (BaseUrl reqScheme reqHost reqPort) = + fmap (setheaders . setrqb . setQS ) $ parseUrl url where url = show $ nullURI { uriScheme = case reqScheme of Http -> "http:" @@ -64,6 +74,10 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = fmap (setrqb . setQS ) $ setrqb r = r { requestBody = RequestBodyLBS (reqBody req) } setQS = setQueryString $ queryTextToQuery (qs req) + setheaders r = r { requestHeaders = Prelude.map toProperHeader (headers req) } + + toProperHeader (name, val) = + (fromString name, encodeUtf8 val) -- * performing requests From fbc012c348dfb4708b27f28f7e8c4a667546e91d Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Wed, 10 Dec 2014 16:51:05 +0100 Subject: [PATCH 09/47] adapt to servant/servant-server split, prepare release --- Setup.hs | 2 ++ docs.sh | 52 ++++++++++++++++++++++++++++++++++++++++++++ servant-client.cabal | 9 ++++---- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 Setup.hs create mode 100644 docs.sh diff --git a/Setup.hs b/Setup.hs new file mode 100644 index 00000000..9a994af6 --- /dev/null +++ b/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/docs.sh b/docs.sh new file mode 100644 index 00000000..a4f6827e --- /dev/null +++ b/docs.sh @@ -0,0 +1,52 @@ +SERVANT_DIR=/tmp/servant-client-gh-pages + +# Make a temporary clone + +rm -rf $SERVANT_DIR + +git clone . $SERVANT_DIR + +cd $SERVANT_DIR + +# Make sure to pull the latest + +git remote add haskell-servant git@github.com:haskell-servant/servant-client.git + +git fetch haskell-servant + +git reset --hard haskell-servant/gh-pages + +# Clear everything away + +git rm -rf $SERVANT_DIR/* + +# Switch back and build the haddocks + +cd - + +cabal configure --builddir=$SERVANT_DIR + +cabal haddock --hoogle --hyperlink-source --html-location='https://hackage.haskell.org/package/$pkg-$version/docs' --builddir=$SERVANT_DIR + +commit_hash=$(git rev-parse HEAD) + +# Move the HTML docs to the root + +cd $SERVANT_DIR + +rm * +rm -rf build +mv doc/html/servant-client/* . +rm -r doc/ + +# Add everything + +git add . + +git commit -m "Built from $commit_hash" + +# Push to update the pages + +git push haskell-servant HEAD:gh-pages + +rm -rf $SERVANT_DIR diff --git a/servant-client.cabal b/servant-client.cabal index fac8899a..1c942eac 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -1,6 +1,6 @@ name: servant-client -version: 0.2 -synopsis: automatical derivation of haskell functions that let you query servant webservices +version: 0.2.1 +synopsis: automatical derivation of querying functions for servant webservices description: This library lets you derive automatically Haskell functions that let you query each endpoint of a webservice. @@ -47,7 +47,7 @@ library , http-types , network-uri >= 2.6 , safe - , servant >= 0.2 + , servant >= 0.2.1 , string-conversions , text , transformers @@ -72,7 +72,8 @@ test-suite spec , http-types , network >= 2.6 , QuickCheck - , servant >= 0.2 + , servant >= 0.2.1 , servant-client + , servant-server >= 0.2.1 , wai , warp From 83afdcb96ba6a6937c5ae4ac8ed4e87621156c0f Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 1 Jan 2015 15:14:07 +0100 Subject: [PATCH 10/47] Declared query string parameters are always sent in requests, even when no value is assigned to them (?name). The server handles a missing query parameter, and a query parameter with no value the same, but to be consistent with the documentation ("If you give Nothing, nothing will be added to the query string.") I made a small change to avoid sending empty query parameters. Also the value and req' parameters were flipped in the lambda in the QueryParams case. --- src/Servant/Client.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index ed63de47..117a03f8 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -210,7 +210,7 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) -- if mparam = Nothing, we don't add it to the query string clientWithRoute Proxy req mparam = clientWithRoute (Proxy :: Proxy sublayout) $ - appendToQueryString pname mparamText req + maybe req (flip (appendToQueryString pname) req . Just) mparamText where pname = cs pname' pname' = symbolVal (Proxy :: Proxy sym) @@ -251,7 +251,7 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) clientWithRoute Proxy req paramlist = clientWithRoute (Proxy :: Proxy sublayout) $ - foldl' (\ value req' -> appendToQueryString pname req' value) req paramlist' + foldl' (\ req' -> maybe req' (flip (appendToQueryString pname) req' . Just)) req paramlist' where pname = cs pname' pname' = symbolVal (Proxy :: Proxy sym) From 8aba885488c42447f5324f2390982ce646125d2f Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 1 Jan 2015 23:43:29 +0100 Subject: [PATCH 11/47] Added support for matrix parameters. --- .gitignore | 17 ++++++ servant-client.cabal | 6 +- src/Servant/Client.hs | 115 +++++++++++++++++++++++++++++++++++++ src/Servant/Common/Req.hs | 7 +++ test/Servant/ClientSpec.hs | 32 ++++++++++- 5 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0855a79b --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +dist +cabal-dev +*.o +*.hi +*.chi +*.chs.h +*.dyn_o +*.dyn_hi +.virtualenv +.hpc +.hsenv +.cabal-sandbox/ +cabal.sandbox.config +cabal.config +*.prof +*.aux +*.hp diff --git a/servant-client.cabal b/servant-client.cabal index 1c942eac..db546f28 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -1,5 +1,5 @@ name: servant-client -version: 0.2.1 +version: 0.2.2 synopsis: automatical derivation of querying functions for servant webservices description: This library lets you derive automatically Haskell functions that @@ -47,7 +47,7 @@ library , http-types , network-uri >= 2.6 , safe - , servant >= 0.2.1 + , servant >= 0.2.2 , string-conversions , text , transformers @@ -71,7 +71,7 @@ test-suite spec , hspec == 2.* , http-types , network >= 2.6 - , QuickCheck + , QuickCheck >= 2.7 , servant >= 0.2.1 , servant-client , servant-server >= 0.2.1 diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 117a03f8..0a507658 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -292,6 +292,121 @@ instance (KnownSymbol sym, HasClient sublayout) where paramname = cs $ symbolVal (Proxy :: Proxy sym) +-- | If you use a 'MatrixParam' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument of the type specified by your 'MatrixParam', +-- enclosed in Maybe. +-- +-- If you give Nothing, nothing will be added to the query string. +-- +-- If you give a non-'Nothing' value, this function will take care +-- of inserting a textual representation of this value in the query string. +-- +-- You can control how values for your type are turned into +-- text by specifying a 'ToText' instance for your type. +-- +-- Example: +-- +-- > type MyApi = "books" :> MatrixParam "author" Text :> Get [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooksBy :: Maybe Text -> BaseUrl -> EitherT String IO [Book] +-- > getBooksBy = client myApi +-- > -- then you can just use "getBooksBy" to query that endpoint. +-- > -- 'getBooksBy Nothing' for all books +-- > -- 'getBooksBy (Just "Isaac Asimov")' to get all books by Isaac Asimov +instance (KnownSymbol sym, ToText a, HasClient sublayout) + => HasClient (MatrixParam sym a :> sublayout) where + + type Client (MatrixParam sym a :> sublayout) = + Maybe a -> Client sublayout + + -- if mparam = Nothing, we don't add it to the query string + clientWithRoute Proxy req mparam = + clientWithRoute (Proxy :: Proxy sublayout) $ + maybe req (flip (appendToMatrixParams pname . Just) req) mparamText + + where pname = symbolVal (Proxy :: Proxy sym) + mparamText = fmap (cs . toText) mparam + +-- | If you use a 'MatrixParams' in one of your endpoints in your API, +-- the corresponding querying function will automatically take an +-- additional argument, a list of values of the type specified by your +-- 'MatrixParams'. +-- +-- If you give an empty list, nothing will be added to the query string. +-- +-- Otherwise, this function will take care of inserting a textual +-- representation of your values in the path segment string, under the +-- same matrix string parameter name. +-- +-- You can control how values for your type are turned into text by +-- specifying a 'ToText' instance for your type. +-- +-- Example: +-- +-- > type MyApi = "books" :> MatrixParams "authors" Text :> Get [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooksBy :: [Text] -> BaseUrl -> EitherT String IO [Book] +-- > getBooksBy = client myApi +-- > -- then you can just use "getBooksBy" to query that endpoint. +-- > -- 'getBooksBy []' for all books +-- > -- 'getBooksBy ["Isaac Asimov", "Robert A. Heinlein"]' +-- > -- to get all books by Asimov and Heinlein +instance (KnownSymbol sym, ToText a, HasClient sublayout) + => HasClient (MatrixParams sym a :> sublayout) where + + type Client (MatrixParams sym a :> sublayout) = + [a] -> Client sublayout + + clientWithRoute Proxy req paramlist = + clientWithRoute (Proxy :: Proxy sublayout) $ + foldl' (\ req' value -> maybe req' (flip (appendToMatrixParams pname) req' . Just . cs) value) req paramlist' + + where pname = cs pname' + pname' = symbolVal (Proxy :: Proxy sym) + paramlist' = map (Just . toText) paramlist + +-- | If you use a 'MatrixFlag' in one of your endpoints in your API, +-- the corresponding querying function will automatically take an +-- additional 'Bool' argument. +-- +-- If you give 'False', nothing will be added to the path segment. +-- +-- Otherwise, this function will insert a value-less matrix parameter +-- under the name associated to your 'MatrixFlag'. +-- +-- Example: +-- +-- > type MyApi = "books" :> MatrixFlag "published" :> Get [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooks :: Bool -> BaseUrl -> EitherT String IO [Book] +-- > getBooks = client myApi +-- > -- then you can just use "getBooks" to query that endpoint. +-- > -- 'getBooksBy False' for all books +-- > -- 'getBooksBy True' to only get _already published_ books +instance (KnownSymbol sym, HasClient sublayout) + => HasClient (MatrixFlag sym :> sublayout) where + + type Client (MatrixFlag sym :> sublayout) = + Bool -> Client sublayout + + clientWithRoute Proxy req flag = + clientWithRoute (Proxy :: Proxy sublayout) $ + if flag + then appendToMatrixParams paramname Nothing req + else req + + where paramname = cs $ symbolVal (Proxy :: Proxy sym) + -- | Pick a 'Method' and specify where the server you want to query is. You get -- back the status code and the response body as a 'ByteString'. instance HasClient Raw where diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 9f7db5c5..d48e3905 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -41,6 +41,13 @@ appendToPath :: String -> Req -> Req appendToPath p req = req { reqPath = reqPath req ++ "/" ++ p } +appendToMatrixParams :: String + -> Maybe String + -> Req + -> Req +appendToMatrixParams pname pvalue req = + req { reqPath = reqPath req ++ ";" ++ pname ++ maybe "" ("=" ++) pvalue } + appendToQueryString :: Text -- ^ param name -> Maybe Text -- ^ param value -> Req diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index c1a2c5d6..b3e2b263 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -4,7 +4,7 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE OverloadedStrings #-} - +{-# OPTIONS_GHC -fcontext-stack=25 #-} module Servant.ClientSpec where import Control.Concurrent @@ -50,6 +50,9 @@ type Api = :<|> "param" :> QueryParam "name" String :> Get Person :<|> "params" :> QueryParams "names" String :> Get [Person] :<|> "flag" :> QueryFlag "flag" :> Get Bool + :<|> "matrixparam" :> MatrixParam "name" String :> Get Person + :<|> "matrixparams" :> MatrixParams "name" String :> Get [Person] + :<|> "matrixflag" :> MatrixFlag "flag" :> Get Bool :<|> "rawSuccess" :> Raw :<|> "rawFailure" :> Raw :<|> "multiple" :> @@ -72,6 +75,12 @@ server = serve api ( Nothing -> left (400, "missing parameter")) :<|> (\ names -> return (zipWith Person names [0..])) :<|> return + :<|> (\ name -> case name of + Just "alice" -> return alice + Just name -> left (400, name ++ " not found") + Nothing -> left (400, "missing parameter")) + :<|> (\ names -> return (zipWith Person names [0..])) + :<|> return :<|> (\ _request respond -> respond $ responseLBS ok200 [] "rawSuccess") :<|> (\ _request respond -> respond $ responseLBS badRequest400 [] "rawFailure") :<|> \ a b c d -> return (a, b, c, d) @@ -86,6 +95,9 @@ getBody :: Person -> BaseUrl -> EitherT String IO Person getQueryParam :: Maybe String -> BaseUrl -> EitherT String IO Person getQueryParams :: [String] -> BaseUrl -> EitherT String IO [Person] getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool +getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person +getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person] +getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] @@ -97,6 +109,9 @@ getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] :<|> getQueryParam :<|> getQueryParams :<|> getQueryFlag + :<|> getMatrixParam + :<|> getMatrixParams + :<|> getMatrixFlag :<|> getRawSuccess :<|> getRawFailure :<|> getMultiple) @@ -129,6 +144,21 @@ spec = do it (show flag) $ withServer $ \ host -> do runEitherT (getQueryFlag flag host) `shouldReturn` Right flag + it "Servant.API.MatrixParam" $ withServer $ \ host -> do + runEitherT (getMatrixParam (Just "alice") host) `shouldReturn` Right alice + Left result <- runEitherT (getMatrixParam (Just "bob") host) + result `shouldContain` "bob not found" + + it "Servant.API.MatrixParam.MatrixParams" $ withServer $ \ host -> do + runEitherT (getMatrixParams [] host) `shouldReturn` Right [] + runEitherT (getMatrixParams ["alice", "bob"] host) + `shouldReturn` Right [Person "alice" 0, Person "bob" 1] + + context "Servant.API.MatrixParam.MatrixFlag" $ + forM_ [False, True] $ \ flag -> + it (show flag) $ withServer $ \ host -> do + runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag + it "Servant.API.Raw on success" $ withServer $ \ host -> do runEitherT (getRawSuccess methodGet host) `shouldReturn` Right (200, "rawSuccess") From 4228447e91ec2de484821120b5840616afc24b31 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Fri, 23 Jan 2015 09:18:13 +1100 Subject: [PATCH 12/47] Add TLS support --- servant-client.cabal | 1 + src/Servant/Common/Req.hs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/servant-client.cabal b/servant-client.cabal index db546f28..2b17dc21 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -44,6 +44,7 @@ library , either , exceptions , http-client + , http-client-tls , http-types , network-uri >= 2.6 , safe diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index d48e3905..71e2447a 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -19,6 +19,7 @@ import Data.String.Conversions import Data.Text import Data.Text.Encoding import Network.HTTP.Client +import Network.HTTP.Client.TLS import Network.HTTP.Types import Network.URI import Servant.Common.BaseUrl @@ -91,7 +92,7 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = {-# NOINLINE __manager #-} __manager :: MVar Manager -__manager = unsafePerformIO (newManager defaultManagerSettings >>= newMVar) +__manager = unsafePerformIO (newManager tlsManagerSettings >>= newMVar) __withGlobalManager :: (Manager -> IO a) -> IO a __withGlobalManager action = modifyMVar __manager $ \ manager -> do From 015a6feeea1d9083e44ca17a2169487d9e5af94c Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Wed, 28 Jan 2015 21:00:28 +1100 Subject: [PATCH 13/47] Travis: use current HEAD of servant-server --- .travis.yml | 2 ++ test/Servant/ClientSpec.hs | 1 + 2 files changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 41be6fa3..b303ae64 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,10 @@ ghc: before_install: - git clone https://github.com/haskell-servant/servant.git + - git clone https://github.com/haskell-servant/servant-server.git - cabal sandbox init - cabal sandbox add-source servant/ + - cabal sandbox add-source servant-server/ notifications: irc: diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index b3e2b263..33fd8e40 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -246,3 +246,4 @@ pathGen = listOf $ elements $ filter (not . (`elem` "?%[]/#")) $ filter isPrint $ map chr [0..127] + From 56e68bc737247635a9ab7970fba3befe81698ad1 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Fri, 30 Jan 2015 17:05:01 +1100 Subject: [PATCH 14/47] Don't try to parse the empty response body for Delete --- src/Servant/Client.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 0a507658..6a0f4f6b 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -13,6 +13,7 @@ module Servant.Client , module Servant.Common.BaseUrl ) where +import Control.Monad import Control.Monad.Trans.Either import Data.Aeson import Data.ByteString.Lazy (ByteString) @@ -108,7 +109,7 @@ instance HasClient Delete where type Client Delete = BaseUrl -> EitherT String IO () clientWithRoute Proxy req host = - performRequestJSON H.methodDelete req 204 host + void $ performRequest H.methodDelete req (== 204) host -- | If you have a 'Get' endpoint in your API, the client -- side querying function that is created when calling 'client' From e5db50917b4cb65e36027f05c8657ffa3e0364ae Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 2 Feb 2015 09:33:05 +1100 Subject: [PATCH 15/47] Add test for 'Delete' --- test/Servant/ClientSpec.hs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index b3e2b263..ced45224 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -45,6 +45,7 @@ alice = Person "Alice" 42 type Api = "get" :> Get Person + :<|> "delete" :> Delete :<|> "capture" :> Capture "name" String :> Get Person :<|> "body" :> ReqBody Person :> Post Person :<|> "param" :> QueryParam "name" String :> Get Person @@ -67,6 +68,7 @@ api = Proxy server :: Application server = serve api ( return alice + :<|> return () :<|> (\ name -> return $ Person name 0) :<|> return :<|> (\ name -> case name of @@ -90,6 +92,7 @@ withServer :: (BaseUrl -> IO a) -> IO a withServer action = withWaiDaemon (return server) action getGet :: BaseUrl -> EitherT String IO Person +getDelete :: BaseUrl -> EitherT String IO () getCapture :: String -> BaseUrl -> EitherT String IO Person getBody :: Person -> BaseUrl -> EitherT String IO Person getQueryParam :: Maybe String -> BaseUrl -> EitherT String IO Person @@ -104,6 +107,7 @@ getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] -> BaseUrl -> EitherT String IO (String, Maybe Int, Bool, [(String, [Rational])]) ( getGet + :<|> getDelete :<|> getCapture :<|> getBody :<|> getQueryParam @@ -122,6 +126,9 @@ spec = do it "Servant.API.Get" $ withServer $ \ host -> do runEitherT (getGet host) `shouldReturn` Right alice + it "Servant.API.Delete" $ withServer $ \ host -> do + runEitherT (getDelete host) `shouldReturn` Right () + it "Servant.API.Capture" $ withServer $ \ host -> do runEitherT (getCapture "Paula" host) `shouldReturn` Right (Person "Paula" 0) From 0edb828c1d46f2e5e3dda93a30803d30e130b46b Mon Sep 17 00:00:00 2001 From: Christian Marie Date: Mon, 2 Feb 2015 13:35:10 +1100 Subject: [PATCH 16/47] Fix tests -- Capture cannot be empty --- test/Servant/ClientSpec.hs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index c439bf8e..b93c0209 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -174,12 +174,12 @@ spec = do modifyMaxSuccess (const 20) $ do it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ - property $ forAllShrink pathGen shrink $ \ a -> \ b c d -> + property $ forAllShrink pathGen shrink $ \(NonEmpty cap) num flag body -> ioProperty $ do withServer $ \ host -> do - result <- runEitherT (getMultiple a b c d host) + result <- runEitherT (getMultiple cap num flag body host) return $ - result === Right (a, b, c, d) + result === Right (cap, num, flag, body) context "client correctly handles error status codes" $ do @@ -248,9 +248,11 @@ openTestSocket = do port <- socketPort s return (fromIntegral port, s) -pathGen :: Gen String -pathGen = listOf $ elements $ - filter (not . (`elem` "?%[]/#")) $ - filter isPrint $ - map chr [0..127] +pathGen :: Gen (NonEmptyList Char) +pathGen = fmap NonEmpty path + where + path = listOf1 $ elements $ + filter (not . (`elem` "?%[]/#;")) $ + filter isPrint $ + map chr [0..127] From 3b48ca7d4380a47535980157c55f4cb619df45ff Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 6 Feb 2015 09:34:59 +0100 Subject: [PATCH 17/47] Cleanup. --- README.md | 2 +- src/Servant/Common/Req.hs | 2 +- test/Servant/ClientSpec.hs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1315658a..7bcce2dc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![servant](https://raw.githubusercontent.com/haskell-servant/servant/master/servant.png) -This library lets you derive automatically Haskell functions that let you query each endpoint of a *servant* webservice. +This library lets you automatically derive Haskell functions that let you query each endpoint of a *servant* webservice. ## Example diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 71e2447a..da85c02a 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -85,7 +85,7 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = setheaders r = r { requestHeaders = Prelude.map toProperHeader (headers req) } toProperHeader (name, val) = - (fromString name, encodeUtf8 val) + (fromString name, encodeUtf8 val) -- * performing requests diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index b93c0209..429a2fe4 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -255,4 +255,3 @@ pathGen = fmap NonEmpty path filter (not . (`elem` "?%[]/#;")) $ filter isPrint $ map chr [0..127] - From a723a0f8d59fe26e87d385889469a53e8dbf91b6 Mon Sep 17 00:00:00 2001 From: "Julian K. Arni" Date: Sun, 15 Feb 2015 08:37:52 +0100 Subject: [PATCH 18/47] Use 'master' servant and servant-server for travis. --- .travis.yml | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index b303ae64..40d79c97 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,39 @@ language: haskell -ghc: - - 7.8 +env: +- GHCVER=7.8.3 before_install: - - git clone https://github.com/haskell-servant/servant.git - - git clone https://github.com/haskell-servant/servant-server.git - - cabal sandbox init - - cabal sandbox add-source servant/ - - cabal sandbox add-source servant-server/ + - | + if [ $GHCVER = `ghc --numeric-version` ]; then + travis/cabal-apt-install --enable-tests $MODE + export CABAL=cabal + else + travis_retry sudo add-apt-repository -y ppa:hvr/ghc + travis_retry sudo apt-get update + travis_retry sudo apt-get install cabal-install-1.18 ghc-$GHCVER happy + export CABAL=cabal-1.18 + export PATH=/opt/ghc/$GHCVER/bin:$PATH + fi + - $CABAL update + - | + if [ $GHCVER = "head" ] || [ $GHCVER = "7.8.3" ]; then + $CABAL install happy alex + export PATH=$HOME/.cabal/bin:$PATH + fi + - git clone https://github.com/haskell-servant/servant.git + - git clone https://github.com/haskell-servant/servant-server.git + - cabal sandbox init + - cabal sandbox add-source servant + - cabal sandbox add-source servant-server +install: + - cabal install --only-dependencies --enable-tests + +script: + - cabal configure --enable-tests + - cabal build && cabal test + - cabal sdist notifications: irc: channels: From 7a1eac4e8662c41f279f447c7a44f3a8974c7581 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 10:05:39 +1100 Subject: [PATCH 19/47] Correctly set the content type for ReqBody --- servant-client.cabal | 1 + src/Servant/Client.hs | 3 ++- src/Servant/Common/Req.hs | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index 2b17dc21..b27e5bc2 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -45,6 +45,7 @@ library , exceptions , http-client , http-client-tls + , http-media , http-types , network-uri >= 2.6 , safe diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 6a0f4f6b..69a8d71d 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -22,6 +22,7 @@ import Data.Proxy import Data.String.Conversions import Data.Text (unpack) import GHC.TypeLits +import Network.HTTP.Media import qualified Network.HTTP.Types as H import Servant.API import Servant.Common.BaseUrl @@ -443,7 +444,7 @@ instance (ToJSON a, HasClient sublayout) clientWithRoute Proxy req body = clientWithRoute (Proxy :: Proxy sublayout) $ - setRQBody (encode body) req + setRQBody (encode body) ("application" // "json" /: ("charset", "utf-8")) req -- | Make the querying function append @path@ to the request path. instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index da85c02a..0e8cf1c6 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -14,12 +14,14 @@ import Data.Aeson.Parser import Data.Aeson.Types import Data.Attoparsec.ByteString import Data.ByteString.Lazy hiding (pack) +import qualified Data.ByteString.Char8 as BS import Data.String import Data.String.Conversions import Data.Text import Data.Text.Encoding import Network.HTTP.Client import Network.HTTP.Client.TLS +import Network.HTTP.Media import Network.HTTP.Types import Network.URI import Servant.Common.BaseUrl @@ -31,12 +33,12 @@ import qualified Network.HTTP.Client as Client data Req = Req { reqPath :: String , qs :: QueryText - , reqBody :: ByteString + , reqBody :: Maybe (ByteString, MediaType) , headers :: [(String, Text)] } defReq :: Req -defReq = Req "" [] "" [] +defReq = Req "" [] Nothing [] appendToPath :: String -> Req -> Req appendToPath p req = @@ -62,8 +64,8 @@ addHeader name val req = req { headers = headers req ++ [(name, toText val)] } -setRQBody :: ByteString -> Req -> Req -setRQBody b req = req { reqBody = b } +setRQBody :: ByteString -> MediaType -> Req -> Req +setRQBody b t req = req { reqBody = Just (b, t) } reqToRequest :: (Functor m, MonadThrow m) => Req -> BaseUrl -> m Request reqToRequest req (BaseUrl reqScheme reqHost reqPort) = @@ -80,9 +82,13 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = , uriPath = reqPath req } - setrqb r = r { requestBody = RequestBodyLBS (reqBody req) } + setrqb r = case (reqBody req) of + Nothing -> r + Just (b,t) -> r { requestBody = RequestBodyLBS b + , requestHeaders = [(hContentType, BS.pack . show $ t)] } setQS = setQueryString $ queryTextToQuery (qs req) - setheaders r = r { requestHeaders = Prelude.map toProperHeader (headers req) } + setheaders r = r { requestHeaders = requestHeaders r + ++ Prelude.map toProperHeader (headers req) } toProperHeader (name, val) = (fromString name, encodeUtf8 val) From db2c5a42b2852f0e627e7dea0e27e2b96bed3354 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 10:32:15 +1100 Subject: [PATCH 20/47] Expose content type in response. --- servant-client.cabal | 1 + src/Servant/Client.hs | 2 +- src/Servant/Common/Req.hs | 14 +++++++++----- test/Servant/ClientSpec.hs | 9 +++++---- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index b27e5bc2..a5576cbc 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -71,6 +71,7 @@ test-suite spec , deepseq , either , hspec == 2.* + , http-media , http-types , network >= 2.6 , QuickCheck >= 2.7 diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 69a8d71d..88bdc249 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -412,7 +412,7 @@ instance (KnownSymbol sym, HasClient sublayout) -- | Pick a 'Method' and specify where the server you want to query is. You get -- back the status code and the response body as a 'ByteString'. instance HasClient Raw where - type Client Raw = H.Method -> BaseUrl -> EitherT String IO (Int, ByteString) + type Client Raw = H.Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) clientWithRoute :: Proxy Raw -> Req -> Client Raw clientWithRoute Proxy req httpMethod host = diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 0e8cf1c6..13b12285 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -14,7 +14,6 @@ import Data.Aeson.Parser import Data.Aeson.Types import Data.Attoparsec.ByteString import Data.ByteString.Lazy hiding (pack) -import qualified Data.ByteString.Char8 as BS import Data.String import Data.String.Conversions import Data.Text @@ -85,7 +84,7 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = setrqb r = case (reqBody req) of Nothing -> r Just (b,t) -> r { requestBody = RequestBodyLBS b - , requestHeaders = [(hContentType, BS.pack . show $ t)] } + , requestHeaders = [(hContentType, cs . show $ t)] } setQS = setQueryString $ queryTextToQuery (qs req) setheaders r = r { requestHeaders = requestHeaders r ++ Prelude.map toProperHeader (headers req) } @@ -110,7 +109,7 @@ displayHttpRequest :: Method -> String displayHttpRequest httpmethod = "HTTP " ++ cs httpmethod ++ " request" -performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT String IO (Int, ByteString) +performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) performRequest reqMethod req isWantedStatus reqHost = do partialRequest <- liftIO $ reqToRequest req reqHost @@ -129,7 +128,12 @@ performRequest reqMethod req isWantedStatus reqHost = do let status = Client.responseStatus response unless (isWantedStatus (statusCode status)) $ left (displayHttpRequest reqMethod ++ " failed with status: " ++ showStatus status) - return $ (statusCode status, Client.responseBody response) + ct <- case lookup "Content-Type" $ Client.responseHeaders response of + Nothing -> pure $ "application"//"octet-stream" + Just t -> case parseAccept t of + Nothing -> left $ "invalid Content-Type header: " <> cs t + Just t' -> pure t' + return $ (statusCode status, Client.responseBody response, ct) where showStatus (Status code message) = show code ++ " - " ++ cs message @@ -138,7 +142,7 @@ performRequest reqMethod req isWantedStatus reqHost = do performRequestJSON :: FromJSON result => Method -> Req -> Int -> BaseUrl -> EitherT String IO result performRequestJSON reqMethod req wantedStatus reqHost = do - (_status, respBody) <- performRequest reqMethod req (== wantedStatus) reqHost + (_status, respBody, _) <- performRequest reqMethod req (== wantedStatus) reqHost either (\ message -> left (displayHttpRequest reqMethod ++ " returned invalid json: " ++ message)) return diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 429a2fe4..70919bdf 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -17,6 +17,7 @@ import Data.Foldable (forM_) import Data.Proxy import Data.Typeable import GHC.Generics +import Network.HTTP.Media import Network.HTTP.Types import Network.Socket import Network.Wai @@ -101,8 +102,8 @@ getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person] getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool -getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) -getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) +getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) +getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] -> BaseUrl -> EitherT String IO (String, Maybe Int, Bool, [(String, [Rational])]) @@ -167,10 +168,10 @@ spec = do runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag it "Servant.API.Raw on success" $ withServer $ \ host -> do - runEitherT (getRawSuccess methodGet host) `shouldReturn` Right (200, "rawSuccess") + runEitherT (getRawSuccess methodGet host) `shouldReturn` Right (200, "rawSuccess", "application"//"octet-stream") it "Servant.API.Raw on failure" $ withServer $ \ host -> do - runEitherT (getRawFailure methodGet host) `shouldReturn` Right (400, "rawFailure") + runEitherT (getRawFailure methodGet host) `shouldReturn` Right (400, "rawFailure", "application"//"octet-stream") modifyMaxSuccess (const 20) $ do it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ From e6e67b275bd0a32f966d93f25566b6a437aa9163 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 10:55:35 +1100 Subject: [PATCH 21/47] Check for correct content-type in result. --- src/Servant/Common/Req.hs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 13b12285..aa2bfdcd 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -142,7 +142,9 @@ performRequest reqMethod req isWantedStatus reqHost = do performRequestJSON :: FromJSON result => Method -> Req -> Int -> BaseUrl -> EitherT String IO result performRequestJSON reqMethod req wantedStatus reqHost = do - (_status, respBody, _) <- performRequest reqMethod req (== wantedStatus) reqHost + (_status, respBody, contentType) <- performRequest reqMethod req (== wantedStatus) reqHost + unless (matches contentType ("application"//"json")) $ + left $ "requested Content-Type application/json, but got " <> show contentType either (\ message -> left (displayHttpRequest reqMethod ++ " returned invalid json: " ++ message)) return From c444ec8374f6f279d35306fa12880d26b49b23ec Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 11:51:59 +1100 Subject: [PATCH 22/47] Send the correct Accept header --- src/Servant/Common/Req.hs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index aa2bfdcd..3832b86d 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -13,10 +13,12 @@ import Data.Aeson import Data.Aeson.Parser import Data.Aeson.Types import Data.Attoparsec.ByteString -import Data.ByteString.Lazy hiding (pack) +import Data.ByteString.Lazy hiding (pack, filter, map) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC import Data.String import Data.String.Conversions -import Data.Text +import Data.Text (Text) import Data.Text.Encoding import Network.HTTP.Client import Network.HTTP.Client.TLS @@ -30,14 +32,15 @@ import System.IO.Unsafe import qualified Network.HTTP.Client as Client data Req = Req - { reqPath :: String - , qs :: QueryText - , reqBody :: Maybe (ByteString, MediaType) - , headers :: [(String, Text)] + { reqPath :: String + , qs :: QueryText + , reqBody :: Maybe (ByteString, MediaType) + , reqAccept :: [MediaType] + , headers :: [(String, Text)] } defReq :: Req -defReq = Req "" [] Nothing [] +defReq = Req "" [] Nothing [] [] appendToPath :: String -> Req -> Req appendToPath p req = @@ -68,7 +71,7 @@ setRQBody b t req = req { reqBody = Just (b, t) } reqToRequest :: (Functor m, MonadThrow m) => Req -> BaseUrl -> m Request reqToRequest req (BaseUrl reqScheme reqHost reqPort) = - fmap (setheaders . setrqb . setQS ) $ parseUrl url + fmap (setheaders . setAccept . setrqb . setQS ) $ parseUrl url where url = show $ nullURI { uriScheme = case reqScheme of Http -> "http:" @@ -84,11 +87,15 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = setrqb r = case (reqBody req) of Nothing -> r Just (b,t) -> r { requestBody = RequestBodyLBS b - , requestHeaders = [(hContentType, cs . show $ t)] } + , requestHeaders = requestHeaders r + ++ [(hContentType, cs . show $ t)] } setQS = setQueryString $ queryTextToQuery (qs req) setheaders r = r { requestHeaders = requestHeaders r ++ Prelude.map toProperHeader (headers req) } - + setAccept r = r { requestHeaders = filter ((/= "Accept") . fst) (requestHeaders r) + ++ [("Accept", BS.intercalate ", " (map renderAccept $ reqAccept req))] } + renderAccept :: MediaType -> BS.ByteString + renderAccept m = BSC.pack (show m) toProperHeader (name, val) = (fromString name, encodeUtf8 val) @@ -142,7 +149,8 @@ performRequest reqMethod req isWantedStatus reqHost = do performRequestJSON :: FromJSON result => Method -> Req -> Int -> BaseUrl -> EitherT String IO result performRequestJSON reqMethod req wantedStatus reqHost = do - (_status, respBody, contentType) <- performRequest reqMethod req (== wantedStatus) reqHost + (_status, respBody, contentType) <- + performRequest reqMethod (req { reqAccept = ["application"//"json"] }) (== wantedStatus) reqHost unless (matches contentType ("application"//"json")) $ left $ "requested Content-Type application/json, but got " <> show contentType either From 098b5036251408e571b843e4264edfdb7f91bab2 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 11:56:15 +1100 Subject: [PATCH 23/47] Don't send an Accept header if the list of accepted types is empty --- src/Servant/Common/Req.hs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 3832b86d..09ea2b03 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -13,7 +13,7 @@ import Data.Aeson import Data.Aeson.Parser import Data.Aeson.Types import Data.Attoparsec.ByteString -import Data.ByteString.Lazy hiding (pack, filter, map) +import Data.ByteString.Lazy hiding (pack, filter, map, null) import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import Data.String @@ -93,7 +93,8 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = setheaders r = r { requestHeaders = requestHeaders r ++ Prelude.map toProperHeader (headers req) } setAccept r = r { requestHeaders = filter ((/= "Accept") . fst) (requestHeaders r) - ++ [("Accept", BS.intercalate ", " (map renderAccept $ reqAccept req))] } + ++ [("Accept", BS.intercalate ", " (map renderAccept $ reqAccept req)) + | not . null . reqAccept $ req] } renderAccept :: MediaType -> BS.ByteString renderAccept m = BSC.pack (show m) toProperHeader (name, val) = From a23204e134eae0e9759e866a04b624c497aa7d6e Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 12:23:03 +1100 Subject: [PATCH 24/47] Use renderHeader to render the Accept header --- src/Servant/Common/Req.hs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 09ea2b03..d6243f6c 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -93,10 +93,8 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = setheaders r = r { requestHeaders = requestHeaders r ++ Prelude.map toProperHeader (headers req) } setAccept r = r { requestHeaders = filter ((/= "Accept") . fst) (requestHeaders r) - ++ [("Accept", BS.intercalate ", " (map renderAccept $ reqAccept req)) + ++ [("Accept", renderHeader $ reqAccept req) | not . null . reqAccept $ req] } - renderAccept :: MediaType -> BS.ByteString - renderAccept m = BSC.pack (show m) toProperHeader (name, val) = (fromString name, encodeUtf8 val) From 6c99dfcb6ce1e62eae1361b4e889c2dcc1612b29 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 13:50:50 +1100 Subject: [PATCH 25/47] Cleanup --- src/Servant/Common/Req.hs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index d6243f6c..746ff066 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -14,8 +14,6 @@ import Data.Aeson.Parser import Data.Aeson.Types import Data.Attoparsec.ByteString import Data.ByteString.Lazy hiding (pack, filter, map, null) -import qualified Data.ByteString as BS -import qualified Data.ByteString.Char8 as BSC import Data.String import Data.String.Conversions import Data.Text (Text) @@ -84,16 +82,16 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = , uriPath = reqPath req } - setrqb r = case (reqBody req) of + setrqb r = case reqBody req of Nothing -> r Just (b,t) -> r { requestBody = RequestBodyLBS b , requestHeaders = requestHeaders r ++ [(hContentType, cs . show $ t)] } setQS = setQueryString $ queryTextToQuery (qs req) setheaders r = r { requestHeaders = requestHeaders r - ++ Prelude.map toProperHeader (headers req) } + <> fmap toProperHeader (headers req) } setAccept r = r { requestHeaders = filter ((/= "Accept") . fst) (requestHeaders r) - ++ [("Accept", renderHeader $ reqAccept req) + <> [("Accept", renderHeader $ reqAccept req) | not . null . reqAccept $ req] } toProperHeader (name, val) = (fromString name, encodeUtf8 val) @@ -139,7 +137,7 @@ performRequest reqMethod req isWantedStatus reqHost = do Just t -> case parseAccept t of Nothing -> left $ "invalid Content-Type header: " <> cs t Just t' -> pure t' - return $ (statusCode status, Client.responseBody response, ct) + return (statusCode status, Client.responseBody response, ct) where showStatus (Status code message) = show code ++ " - " ++ cs message From 48030a6a1ba30f3775648ecc1f73fdf7ce34da7d Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Tue, 17 Feb 2015 17:17:10 +1100 Subject: [PATCH 26/47] Simple design for client with content-types --- servant-client.cabal | 1 + src/Servant/Client.hs | 24 ++++++++++--------- src/Servant/Common/Req.hs | 26 ++++++++++---------- test/Servant/ClientSpec.hs | 49 ++++++++++++++++++++++---------------- 4 files changed, 57 insertions(+), 43 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index a5576cbc..5a04e162 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -50,6 +50,7 @@ library , network-uri >= 2.6 , safe , servant >= 0.2.2 + , servant-server , string-conversions , text , transformers diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 88bdc249..7b2fa16b 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} @@ -26,6 +27,7 @@ import Network.HTTP.Media import qualified Network.HTTP.Types as H import Servant.API import Servant.Common.BaseUrl +import Servant.Server.ContentTypes import Servant.Common.Req import Servant.Common.Text @@ -116,10 +118,10 @@ instance HasClient Delete where -- side querying function that is created when calling 'client' -- will just require an argument that specifies the scheme, host -- and port to send the request to. -instance FromJSON result => HasClient (Get result) where - type Client (Get result) = BaseUrl -> EitherT String IO result +instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where + type Client (Get (ct ': cts) result) = BaseUrl -> EitherT String IO result clientWithRoute Proxy req host = - performRequestJSON H.methodGet req 200 host + performRequestCT (Proxy :: Proxy ct) H.methodGet req 200 host -- | If you use a 'Header' in one of your endpoints in your API, -- the corresponding querying function will automatically take @@ -162,21 +164,21 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) -- side querying function that is created when calling 'client' -- will just require an argument that specifies the scheme, host -- and port to send the request to. -instance FromJSON a => HasClient (Post a) where - type Client (Post a) = BaseUrl -> EitherT String IO a +instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where + type Client (Post (ct ': cts) a) = BaseUrl -> EitherT String IO a clientWithRoute Proxy req uri = - performRequestJSON H.methodPost req 201 uri + performRequestCT (Proxy :: Proxy ct) H.methodPost req 201 uri -- | If you have a 'Put' endpoint in your API, the client -- side querying function that is created when calling 'client' -- will just require an argument that specifies the scheme, host -- and port to send the request to. -instance FromJSON a => HasClient (Put a) where - type Client (Put a) = BaseUrl -> EitherT String IO a +instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where + type Client (Put (ct ': cts) a) = BaseUrl -> EitherT String IO a clientWithRoute Proxy req host = - performRequestJSON H.methodPut req 200 host + performRequestCT (Proxy :: Proxy ct) H.methodPut req 200 host -- | If you use a 'QueryParam' in one of your endpoints in your API, -- the corresponding querying function will automatically take @@ -437,9 +439,9 @@ instance HasClient Raw where -- > addBook = client myApi -- > -- then you can just use "addBook" to query that endpoint instance (ToJSON a, HasClient sublayout) - => HasClient (ReqBody a :> sublayout) where + => HasClient (ReqBody (ct ': cts) a :> sublayout) where - type Client (ReqBody a :> sublayout) = + type Client (ReqBody (ct ': cts) a :> sublayout) = a -> Client sublayout clientWithRoute Proxy req body = diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 746ff066..9a868424 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -16,15 +16,17 @@ import Data.Attoparsec.ByteString import Data.ByteString.Lazy hiding (pack, filter, map, null) import Data.String import Data.String.Conversions +import Data.Proxy import Data.Text (Text) import Data.Text.Encoding -import Network.HTTP.Client +import Network.HTTP.Client hiding (Proxy) import Network.HTTP.Client.TLS import Network.HTTP.Media import Network.HTTP.Types import Network.URI import Servant.Common.BaseUrl import Servant.Common.Text +import Servant.Server.ContentTypes import System.IO.Unsafe import qualified Network.HTTP.Client as Client @@ -142,18 +144,18 @@ performRequest reqMethod req isWantedStatus reqHost = do showStatus (Status code message) = show code ++ " - " ++ cs message - -performRequestJSON :: FromJSON result => - Method -> Req -> Int -> BaseUrl -> EitherT String IO result -performRequestJSON reqMethod req wantedStatus reqHost = do - (_status, respBody, contentType) <- - performRequest reqMethod (req { reqAccept = ["application"//"json"] }) (== wantedStatus) reqHost - unless (matches contentType ("application"//"json")) $ - left $ "requested Content-Type application/json, but got " <> show contentType - either - (\ message -> left (displayHttpRequest reqMethod ++ " returned invalid json: " ++ message)) +performRequestCT :: MimeUnrender ct result => + Proxy ct -> Method -> Req -> Int -> BaseUrl -> EitherT String IO result +performRequestCT ct reqMethod req wantedStatus reqHost = do + let acceptCT = contentType ct + (_status, respBody, respCT) <- + performRequest reqMethod (req { reqAccept = [acceptCT] }) (== wantedStatus) reqHost + unless (matches respCT (acceptCT)) $ + left $ "requested Content-Type " <> show acceptCT <> ", but got " <> show respCT + maybe + (left (displayHttpRequest reqMethod ++ " returned invalid response of type: " ++ show respCT)) return - (decodeLenient respBody) + (fromByteString ct respBody) catchStatusCodeException :: IO a -> IO (Either Status a) diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 70919bdf..40265dd0 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -45,24 +45,26 @@ alice :: Person alice = Person "Alice" 42 type Api = - "get" :> Get Person + "get" :> Get '[JSON] Person :<|> "delete" :> Delete - :<|> "capture" :> Capture "name" String :> Get Person - :<|> "body" :> ReqBody Person :> Post Person - :<|> "param" :> QueryParam "name" String :> Get Person - :<|> "params" :> QueryParams "names" String :> Get [Person] - :<|> "flag" :> QueryFlag "flag" :> Get Bool - :<|> "matrixparam" :> MatrixParam "name" String :> Get Person - :<|> "matrixparams" :> MatrixParams "name" String :> Get [Person] - :<|> "matrixflag" :> MatrixFlag "flag" :> Get Bool + :<|> "capture" :> Capture "name" String :> Get '[JSON] Person + :<|> "body" :> ReqBody '[JSON] Person :> Post '[JSON] Person + :<|> "param" :> QueryParam "name" String :> Get '[JSON] Person + :<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person] + :<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool +{- + :<|> "matrixparam" :> MatrixParam "name" String :> Get '[JSON] Person + :<|> "matrixparams" :> MatrixParams "name" String :> Get '[JSON] [Person] + :<|> "matrixflag" :> MatrixFlag "flag" :> Get '[JSON] Bool +-} :<|> "rawSuccess" :> Raw :<|> "rawFailure" :> Raw :<|> "multiple" :> Capture "first" String :> QueryParam "second" Int :> QueryFlag "third" :> - ReqBody [(String, [Rational])] :> - Get (String, Maybe Int, Bool, [(String, [Rational])]) + ReqBody '[JSON] [(String, [Rational])] :> + Get '[JSON] (String, Maybe Int, Bool, [(String, [Rational])]) api :: Proxy Api api = Proxy @@ -78,12 +80,14 @@ server = serve api ( Nothing -> left (400, "missing parameter")) :<|> (\ names -> return (zipWith Person names [0..])) :<|> return +{- :<|> (\ name -> case name of Just "alice" -> return alice Just name -> left (400, name ++ " not found") Nothing -> left (400, "missing parameter")) :<|> (\ names -> return (zipWith Person names [0..])) :<|> return +-} :<|> (\ _request respond -> respond $ responseLBS ok200 [] "rawSuccess") :<|> (\ _request respond -> respond $ responseLBS badRequest400 [] "rawFailure") :<|> \ a b c d -> return (a, b, c, d) @@ -99,9 +103,11 @@ getBody :: Person -> BaseUrl -> EitherT String IO Person getQueryParam :: Maybe String -> BaseUrl -> EitherT String IO Person getQueryParams :: [String] -> BaseUrl -> EitherT String IO [Person] getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool +{- getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person] getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool +-} getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] @@ -114,9 +120,11 @@ getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] :<|> getQueryParam :<|> getQueryParams :<|> getQueryFlag +{- :<|> getMatrixParam :<|> getMatrixParams :<|> getMatrixFlag +-} :<|> getRawSuccess :<|> getRawFailure :<|> getMultiple) @@ -152,6 +160,7 @@ spec = do it (show flag) $ withServer $ \ host -> do runEitherT (getQueryFlag flag host) `shouldReturn` Right flag +{- it "Servant.API.MatrixParam" $ withServer $ \ host -> do runEitherT (getMatrixParam (Just "alice") host) `shouldReturn` Right alice Left result <- runEitherT (getMatrixParam (Just "bob") host) @@ -166,6 +175,7 @@ spec = do forM_ [False, True] $ \ flag -> it (show flag) $ withServer $ \ host -> do runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag +-} it "Servant.API.Raw on success" $ withServer $ \ host -> do runEitherT (getRawSuccess methodGet host) `shouldReturn` Right (200, "rawSuccess", "application"//"octet-stream") @@ -184,9 +194,9 @@ spec = do context "client correctly handles error status codes" $ do - let test :: WrappedApi -> Spec - test (WrappedApi api) = - it (show (typeOf api)) $ + let test :: (WrappedApi, String) -> Spec + test (WrappedApi api, desc) = + it desc $ withWaiDaemon (return (serve api (left (500, "error message")))) $ \ host -> do let getResponse :: BaseUrl -> EitherT String IO () @@ -194,16 +204,15 @@ spec = do Left result <- runEitherT (getResponse host) result `shouldContain` "error message" mapM_ test $ - (WrappedApi (Proxy :: Proxy Delete)) : - (WrappedApi (Proxy :: Proxy (Get ()))) : - (WrappedApi (Proxy :: Proxy (Post ()))) : - (WrappedApi (Proxy :: Proxy (Put ()))) : + (WrappedApi (Proxy :: Proxy Delete), "Delete") : + (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Delete") : + (WrappedApi (Proxy :: Proxy (Post '[JSON] ())), "Delete") : + (WrappedApi (Proxy :: Proxy (Put '[JSON] ())), "Delete") : [] data WrappedApi where WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, - HasClient api, Client api ~ (BaseUrl -> EitherT String IO ()), - Typeable api) => + HasClient api, Client api ~ (BaseUrl -> EitherT String IO ())) => Proxy api -> WrappedApi From fa0ef86c984fa4a2dd689b734a40bfc3a6a6541f Mon Sep 17 00:00:00 2001 From: "Julian K. Arni" Date: Thu, 19 Feb 2015 20:48:52 +0100 Subject: [PATCH 27/47] Enable coveralls --- .travis.yml | 7 ++++++- README.md | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 40d79c97..383299db 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,9 +31,14 @@ install: - cabal install --only-dependencies --enable-tests script: - - cabal configure --enable-tests + - cabal configure --enable-tests --enable-library-coverage - cabal build && cabal test - cabal sdist + +after_script: + - cabal install hpc-coveralls + - hpc-coveralls --exclude-dir=test spec + notifications: irc: channels: diff --git a/README.md b/README.md index 7bcce2dc..b8ec46a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # servant-client [![Build Status](https://secure.travis-ci.org/haskell-servant/servant-client.svg)](http://travis-ci.org/haskell-servant/servant-client) +[![Coverage Status](https://coveralls.io/repos/haskell-servant/servant-client/badge.svg)](https://coveralls.io/r/haskell-servant/servant-client) ![servant](https://raw.githubusercontent.com/haskell-servant/servant/master/servant.png) From 40a941e0e384d48f52cdb96ce57d80b423389099 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Wed, 25 Feb 2015 09:30:31 +1100 Subject: [PATCH 28/47] Don't ignore the content-type in ReqBody --- servant-client.cabal | 1 - src/Servant/Client.hs | 9 +++++---- src/Servant/Common/Req.hs | 13 +++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index 5a04e162..a5576cbc 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -50,7 +50,6 @@ library , network-uri >= 2.6 , safe , servant >= 0.2.2 - , servant-server , string-conversions , text , transformers diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 7b2fa16b..46887186 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -26,8 +26,8 @@ import GHC.TypeLits import Network.HTTP.Media import qualified Network.HTTP.Types as H import Servant.API +import Servant.API.ContentTypes import Servant.Common.BaseUrl -import Servant.Server.ContentTypes import Servant.Common.Req import Servant.Common.Text @@ -438,15 +438,16 @@ instance HasClient Raw where -- > addBook :: Book -> BaseUrl -> EitherT String IO Book -- > addBook = client myApi -- > -- then you can just use "addBook" to query that endpoint -instance (ToJSON a, HasClient sublayout) +instance (MimeRender ct a, HasClient sublayout) => HasClient (ReqBody (ct ': cts) a :> sublayout) where type Client (ReqBody (ct ': cts) a :> sublayout) = a -> Client sublayout clientWithRoute Proxy req body = - clientWithRoute (Proxy :: Proxy sublayout) $ - setRQBody (encode body) ("application" // "json" /: ("charset", "utf-8")) req + clientWithRoute (Proxy :: Proxy sublayout) $ do + let ctProxy = Proxy :: Proxy ct + setRQBody (toByteString ctProxy body) (contentType ctProxy) req -- | Make the querying function append @path@ to the request path. instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 9a868424..77e077a7 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -24,9 +24,9 @@ import Network.HTTP.Client.TLS import Network.HTTP.Media import Network.HTTP.Types import Network.URI +import Servant.API.ContentTypes import Servant.Common.BaseUrl import Servant.Common.Text -import Servant.Server.ContentTypes import System.IO.Unsafe import qualified Network.HTTP.Client as Client @@ -152,8 +152,8 @@ performRequestCT ct reqMethod req wantedStatus reqHost = do performRequest reqMethod (req { reqAccept = [acceptCT] }) (== wantedStatus) reqHost unless (matches respCT (acceptCT)) $ left $ "requested Content-Type " <> show acceptCT <> ", but got " <> show respCT - maybe - (left (displayHttpRequest reqMethod ++ " returned invalid response of type: " ++ show respCT)) + either + (left . ((displayHttpRequest reqMethod ++ " returned invalid response of type" ++ show respCT) ++)) return (fromByteString ct respBody) @@ -164,10 +164,3 @@ catchStatusCodeException action = case e of Client.StatusCodeException status _ _ -> return $ Left status exc -> throwIO exc - --- | Like 'Data.Aeson.decode' but allows all JSON values instead of just --- objects and arrays. -decodeLenient :: FromJSON a => ByteString -> Either String a -decodeLenient input = do - v :: Value <- parseOnly (Data.Aeson.Parser.value <* endOfInput) (cs input) - parseEither parseJSON v From da0e1ca87133a916760f7414206969bbaea36ecf Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Wed, 25 Feb 2015 09:56:06 +1100 Subject: [PATCH 29/47] Extend tests and clean up --- servant-client.cabal | 1 + src/Servant/Common/Req.hs | 4 ---- test/Servant/ClientSpec.hs | 34 ++++++++++++++++++++++------------ 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index a5576cbc..77b832b7 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -78,5 +78,6 @@ test-suite spec , servant >= 0.2.1 , servant-client , servant-server >= 0.2.1 + , text , wai , warp diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 77e077a7..446bfd12 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -9,10 +9,6 @@ import Control.Monad import Control.Monad.Catch (MonadThrow) import Control.Monad.IO.Class import Control.Monad.Trans.Either -import Data.Aeson -import Data.Aeson.Parser -import Data.Aeson.Types -import Data.Attoparsec.ByteString import Data.ByteString.Lazy hiding (pack, filter, map, null) import Data.String import Data.String.Conversions diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 40265dd0..063c6345 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -3,6 +3,7 @@ {-# LANGUAGE ViewPatterns #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE TypeOperators #-} +{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -fcontext-stack=25 #-} module Servant.ClientSpec where @@ -14,8 +15,9 @@ import Data.Aeson import Data.ByteString.Lazy (ByteString) import Data.Char import Data.Foldable (forM_) +import Data.Monoid import Data.Proxy -import Data.Typeable +import qualified Data.Text as T import GHC.Generics import Network.HTTP.Media import Network.HTTP.Types @@ -27,6 +29,7 @@ import Test.Hspec.QuickCheck import Test.QuickCheck import Servant.API +import Servant.API.ContentTypes import Servant.Client import Servant.Server @@ -41,22 +44,35 @@ data Person = Person { instance ToJSON Person instance FromJSON Person +instance ToFormUrlEncoded Person where + toFormUrlEncoded Person{..} = + [("name", T.pack name), ("age", T.pack (show age))] + +lookupEither :: (Show a, Eq a) => a -> [(a,b)] -> Either String b +lookupEither x xs = do + maybe (Left $ "could not find key " <> show x) return $ lookup x xs + +instance FromFormUrlEncoded Person where + fromFormUrlEncoded xs = do + n <- lookupEither "name" xs + a <- lookupEither "age" xs + return $ Person (T.unpack n) (read $ T.unpack a) + + alice :: Person alice = Person "Alice" 42 type Api = "get" :> Get '[JSON] Person :<|> "delete" :> Delete - :<|> "capture" :> Capture "name" String :> Get '[JSON] Person - :<|> "body" :> ReqBody '[JSON] Person :> Post '[JSON] Person - :<|> "param" :> QueryParam "name" String :> Get '[JSON] Person + :<|> "capture" :> Capture "name" String :> Get '[JSON,FormUrlEncoded] Person + :<|> "body" :> ReqBody '[FormUrlEncoded,JSON] Person :> Post '[JSON] Person + :<|> "param" :> QueryParam "name" String :> Get '[FormUrlEncoded,JSON] Person :<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person] :<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool -{- :<|> "matrixparam" :> MatrixParam "name" String :> Get '[JSON] Person :<|> "matrixparams" :> MatrixParams "name" String :> Get '[JSON] [Person] :<|> "matrixflag" :> MatrixFlag "flag" :> Get '[JSON] Bool --} :<|> "rawSuccess" :> Raw :<|> "rawFailure" :> Raw :<|> "multiple" :> @@ -80,14 +96,12 @@ server = serve api ( Nothing -> left (400, "missing parameter")) :<|> (\ names -> return (zipWith Person names [0..])) :<|> return -{- :<|> (\ name -> case name of Just "alice" -> return alice Just name -> left (400, name ++ " not found") Nothing -> left (400, "missing parameter")) :<|> (\ names -> return (zipWith Person names [0..])) :<|> return --} :<|> (\ _request respond -> respond $ responseLBS ok200 [] "rawSuccess") :<|> (\ _request respond -> respond $ responseLBS badRequest400 [] "rawFailure") :<|> \ a b c d -> return (a, b, c, d) @@ -103,11 +117,9 @@ getBody :: Person -> BaseUrl -> EitherT String IO Person getQueryParam :: Maybe String -> BaseUrl -> EitherT String IO Person getQueryParams :: [String] -> BaseUrl -> EitherT String IO [Person] getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool -{- getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person] getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool --} getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] @@ -120,11 +132,9 @@ getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] :<|> getQueryParam :<|> getQueryParams :<|> getQueryFlag -{- :<|> getMatrixParam :<|> getMatrixParams :<|> getMatrixFlag --} :<|> getRawSuccess :<|> getRawFailure :<|> getMultiple) From 1af0d7b5ab1726537738feb2c8a42fe5c3230e02 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Wed, 4 Mar 2015 01:59:17 +0100 Subject: [PATCH 30/47] tentative changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..fb0443d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +0.3 +--- +* Support content-type aware combinators and `Accept`/`Content-type` headers +* Added a lot of tests + +0.2.2 +----- +* Add TLS support +* Add matrix parameter support \ No newline at end of file From aeb1136658a186537f995a78fa3bf311d149087a Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Thu, 5 Mar 2015 09:49:15 +1100 Subject: [PATCH 31/47] Use an `IORef` rather than a `MVar`. The job of a manager is to synchronize threads, so it is not necessary to block. --- src/Servant/Common/Req.hs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 446bfd12..3f27469c 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -3,13 +3,13 @@ module Servant.Common.Req where import Control.Applicative -import Control.Concurrent import Control.Exception import Control.Monad import Control.Monad.Catch (MonadThrow) import Control.Monad.IO.Class import Control.Monad.Trans.Either import Data.ByteString.Lazy hiding (pack, filter, map, null) +import Data.IORef import Data.String import Data.String.Conversions import Data.Proxy @@ -98,13 +98,11 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) = -- * performing requests {-# NOINLINE __manager #-} -__manager :: MVar Manager -__manager = unsafePerformIO (newManager tlsManagerSettings >>= newMVar) +__manager :: IORef Manager +__manager = unsafePerformIO (newManager tlsManagerSettings >>= newIORef) __withGlobalManager :: (Manager -> IO a) -> IO a -__withGlobalManager action = modifyMVar __manager $ \ manager -> do - result <- action manager - return (manager, result) +__withGlobalManager action = readIORef __manager >>= action displayHttpRequest :: Method -> String From 449b3758f0e0e492566b7b12f905c4ff8fcc6273 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Thu, 5 Mar 2015 10:36:34 +1100 Subject: [PATCH 32/47] Changelog for MVar-change --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0443d9..0d76c9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ --- * Support content-type aware combinators and `Accept`/`Content-type` headers * Added a lot of tests +* Support multiple concurrent threads 0.2.2 ----- * Add TLS support -* Add matrix parameter support \ No newline at end of file +* Add matrix parameter support From ba46ecc0a9cfba1ac2d5fd8286df8416f8a367ea Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Thu, 5 Mar 2015 12:46:35 +1100 Subject: [PATCH 33/47] Use `ServantError` to report Errors instead of `String` --- CHANGELOG.md | 1 + src/Servant/Client.hs | 13 ++++---- src/Servant/Common/Req.hs | 44 ++++++++++++++------------ test/Servant/ClientSpec.hs | 65 ++++++++++++++++++++------------------ 4 files changed, 65 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d76c9d4..4fe8131d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Support content-type aware combinators and `Accept`/`Content-type` headers * Added a lot of tests * Support multiple concurrent threads +* Use `ServantError` to report Errors instead of `String` 0.2.2 ----- diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 46887186..4479223e 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -11,12 +11,12 @@ module Servant.Client ( client , HasClient(..) + , ServantError(..) , module Servant.Common.BaseUrl ) where import Control.Monad import Control.Monad.Trans.Either -import Data.Aeson import Data.ByteString.Lazy (ByteString) import Data.List import Data.Proxy @@ -29,7 +29,6 @@ import Servant.API import Servant.API.ContentTypes import Servant.Common.BaseUrl import Servant.Common.Req -import Servant.Common.Text -- * Accessing APIs as a Client @@ -109,7 +108,7 @@ instance (KnownSymbol capture, ToText a, HasClient sublayout) -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance HasClient Delete where - type Client Delete = BaseUrl -> EitherT String IO () + type Client Delete = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = void $ performRequest H.methodDelete req (== 204) host @@ -119,7 +118,7 @@ instance HasClient Delete where -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where - type Client (Get (ct ': cts) result) = BaseUrl -> EitherT String IO result + type Client (Get (ct ': cts) result) = BaseUrl -> EitherT ServantError IO result clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodGet req 200 host @@ -165,7 +164,7 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where - type Client (Post (ct ': cts) a) = BaseUrl -> EitherT String IO a + type Client (Post (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req uri = performRequestCT (Proxy :: Proxy ct) H.methodPost req 201 uri @@ -175,7 +174,7 @@ instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where - type Client (Put (ct ': cts) a) = BaseUrl -> EitherT String IO a + type Client (Put (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodPut req 200 host @@ -414,7 +413,7 @@ instance (KnownSymbol sym, HasClient sublayout) -- | Pick a 'Method' and specify where the server you want to query is. You get -- back the status code and the response body as a 'ByteString'. instance HasClient Raw where - type Client Raw = H.Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) + type Client Raw = H.Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) clientWithRoute :: Proxy Raw -> Req -> Client Raw clientWithRoute Proxy req httpMethod host = diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 3f27469c..7405e5cd 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -27,6 +27,14 @@ import System.IO.Unsafe import qualified Network.HTTP.Client as Client +data ServantError + = FailureResponse Status MediaType ByteString + | DecodeFailure String MediaType ByteString + | UnsupportedContentType MediaType ByteString + | ConnectionError HttpException + | InvalidContentTypeHeader String + deriving (Show) + data Req = Req { reqPath :: String , qs :: QueryText @@ -109,7 +117,7 @@ displayHttpRequest :: Method -> String displayHttpRequest httpmethod = "HTTP " ++ cs httpmethod ++ " request" -performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) +performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) performRequest reqMethod req isWantedStatus reqHost = do partialRequest <- liftIO $ reqToRequest req reqHost @@ -118,43 +126,39 @@ performRequest reqMethod req isWantedStatus reqHost = do } eResponse <- liftIO $ __withGlobalManager $ \ manager -> - catchStatusCodeException $ + catchHttpException $ Client.httpLbs request manager case eResponse of - Left status -> - left (displayHttpRequest reqMethod ++ " failed with status: " ++ showStatus status) + Left err -> + left $ ConnectionError err Right response -> do let status = Client.responseStatus response - unless (isWantedStatus (statusCode status)) $ - left (displayHttpRequest reqMethod ++ " failed with status: " ++ showStatus status) + body = Client.responseBody response + status_code = statusCode status ct <- case lookup "Content-Type" $ Client.responseHeaders response of Nothing -> pure $ "application"//"octet-stream" Just t -> case parseAccept t of - Nothing -> left $ "invalid Content-Type header: " <> cs t + Nothing -> left . InvalidContentTypeHeader . cs $ t Just t' -> pure t' - return (statusCode status, Client.responseBody response, ct) - where - showStatus (Status code message) = - show code ++ " - " ++ cs message + unless (isWantedStatus status_code) $ + left $ FailureResponse status ct body + return (status_code, body, ct) performRequestCT :: MimeUnrender ct result => - Proxy ct -> Method -> Req -> Int -> BaseUrl -> EitherT String IO result + Proxy ct -> Method -> Req -> Int -> BaseUrl -> EitherT ServantError IO result performRequestCT ct reqMethod req wantedStatus reqHost = do let acceptCT = contentType ct (_status, respBody, respCT) <- performRequest reqMethod (req { reqAccept = [acceptCT] }) (== wantedStatus) reqHost unless (matches respCT (acceptCT)) $ - left $ "requested Content-Type " <> show acceptCT <> ", but got " <> show respCT + left $ UnsupportedContentType respCT respBody either - (left . ((displayHttpRequest reqMethod ++ " returned invalid response of type" ++ show respCT) ++)) + (left . (\s -> DecodeFailure s respCT respBody)) return (fromByteString ct respBody) -catchStatusCodeException :: IO a -> IO (Either Status a) -catchStatusCodeException action = - catch (Right <$> action) $ \e -> - case e of - Client.StatusCodeException status _ _ -> return $ Left status - exc -> throwIO exc +catchHttpException :: IO a -> IO (Either HttpException a) +catchHttpException action = + catch (Right <$> action) (pure . Left) diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 063c6345..51abeacc 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -8,6 +8,8 @@ {-# OPTIONS_GHC -fcontext-stack=25 #-} module Servant.ClientSpec where +import Control.Applicative +import qualified Control.Arrow as Arrow import Control.Concurrent import Control.Exception import Control.Monad.Trans.Either @@ -29,7 +31,6 @@ import Test.Hspec.QuickCheck import Test.QuickCheck import Servant.API -import Servant.API.ContentTypes import Servant.Client import Servant.Server @@ -110,21 +111,21 @@ server = serve api ( withServer :: (BaseUrl -> IO a) -> IO a withServer action = withWaiDaemon (return server) action -getGet :: BaseUrl -> EitherT String IO Person -getDelete :: BaseUrl -> EitherT String IO () -getCapture :: String -> BaseUrl -> EitherT String IO Person -getBody :: Person -> BaseUrl -> EitherT String IO Person -getQueryParam :: Maybe String -> BaseUrl -> EitherT String IO Person -getQueryParams :: [String] -> BaseUrl -> EitherT String IO [Person] -getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool -getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person -getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person] -getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool -getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) -getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType) +getGet :: BaseUrl -> EitherT ServantError IO Person +getDelete :: BaseUrl -> EitherT ServantError IO () +getCapture :: String -> BaseUrl -> EitherT ServantError IO Person +getBody :: Person -> BaseUrl -> EitherT ServantError IO Person +getQueryParam :: Maybe String -> BaseUrl -> EitherT ServantError IO Person +getQueryParams :: [String] -> BaseUrl -> EitherT ServantError IO [Person] +getQueryFlag :: Bool -> BaseUrl -> EitherT ServantError IO Bool +getMatrixParam :: Maybe String -> BaseUrl -> EitherT ServantError IO Person +getMatrixParams :: [String] -> BaseUrl -> EitherT ServantError IO [Person] +getMatrixFlag :: Bool -> BaseUrl -> EitherT ServantError IO Bool +getRawSuccess :: Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) +getRawFailure :: Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] -> BaseUrl - -> EitherT String IO (String, Maybe Int, Bool, [(String, [Rational])]) + -> EitherT ServantError IO (String, Maybe Int, Bool, [(String, [Rational])]) ( getGet :<|> getDelete :<|> getCapture @@ -143,32 +144,32 @@ getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] spec :: Spec spec = do it "Servant.API.Get" $ withServer $ \ host -> do - runEitherT (getGet host) `shouldReturn` Right alice + (Arrow.left show <$> runEitherT (getGet host)) `shouldReturn` Right alice it "Servant.API.Delete" $ withServer $ \ host -> do - runEitherT (getDelete host) `shouldReturn` Right () + (Arrow.left show <$> runEitherT (getDelete host)) `shouldReturn` Right () it "Servant.API.Capture" $ withServer $ \ host -> do - runEitherT (getCapture "Paula" host) `shouldReturn` Right (Person "Paula" 0) + (Arrow.left show <$> runEitherT (getCapture "Paula" host)) `shouldReturn` Right (Person "Paula" 0) it "Servant.API.ReqBody" $ withServer $ \ host -> do let p = Person "Clara" 42 - runEitherT (getBody p host) `shouldReturn` Right p + (Arrow.left show <$> runEitherT (getBody p host)) `shouldReturn` Right p it "Servant.API.QueryParam" $ withServer $ \ host -> do - runEitherT (getQueryParam (Just "alice") host) `shouldReturn` Right alice - Left result <- runEitherT (getQueryParam (Just "bob") host) - result `shouldContain` "bob not found" + (Arrow.left show <$> runEitherT (getQueryParam (Just "alice") host)) `shouldReturn` Right alice + Left (FailureResponse s _ _) <- runEitherT (getQueryParam (Just "bob") host) + statusCode s `shouldBe` 400 it "Servant.API.QueryParam.QueryParams" $ withServer $ \ host -> do - runEitherT (getQueryParams [] host) `shouldReturn` Right [] - runEitherT (getQueryParams ["alice", "bob"] host) + (Arrow.left show <$> runEitherT (getQueryParams [] host)) `shouldReturn` Right [] + (Arrow.left show <$> runEitherT (getQueryParams ["alice", "bob"] host)) `shouldReturn` Right [Person "alice" 0, Person "bob" 1] context "Servant.API.QueryParam.QueryFlag" $ forM_ [False, True] $ \ flag -> it (show flag) $ withServer $ \ host -> do - runEitherT (getQueryFlag flag host) `shouldReturn` Right flag + (Arrow.left show <$> runEitherT (getQueryFlag flag host)) `shouldReturn` Right flag {- it "Servant.API.MatrixParam" $ withServer $ \ host -> do @@ -188,17 +189,19 @@ spec = do -} it "Servant.API.Raw on success" $ withServer $ \ host -> do - runEitherT (getRawSuccess methodGet host) `shouldReturn` Right (200, "rawSuccess", "application"//"octet-stream") + (Arrow.left show <$> runEitherT (getRawSuccess methodGet host)) + `shouldReturn` Right (200, "rawSuccess", "application"//"octet-stream") it "Servant.API.Raw on failure" $ withServer $ \ host -> do - runEitherT (getRawFailure methodGet host) `shouldReturn` Right (400, "rawFailure", "application"//"octet-stream") + (Arrow.left show <$> runEitherT (getRawFailure methodGet host)) + `shouldReturn` Right (400, "rawFailure", "application"//"octet-stream") modifyMaxSuccess (const 20) $ do it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ property $ forAllShrink pathGen shrink $ \(NonEmpty cap) num flag body -> ioProperty $ do withServer $ \ host -> do - result <- runEitherT (getMultiple cap num flag body host) + result <- Arrow.left show <$> runEitherT (getMultiple cap num flag body host) return $ result === Right (cap, num, flag, body) @@ -209,10 +212,10 @@ spec = do it desc $ withWaiDaemon (return (serve api (left (500, "error message")))) $ \ host -> do - let getResponse :: BaseUrl -> EitherT String IO () + let getResponse :: BaseUrl -> EitherT ServantError IO () getResponse = client api - Left result <- runEitherT (getResponse host) - result `shouldContain` "error message" + Left (FailureResponse status _ _) <- runEitherT (getResponse host) + status `shouldBe` (Status 500 "error message") mapM_ test $ (WrappedApi (Proxy :: Proxy Delete), "Delete") : (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Delete") : @@ -222,7 +225,7 @@ spec = do data WrappedApi where WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, - HasClient api, Client api ~ (BaseUrl -> EitherT String IO ())) => + HasClient api, Client api ~ (BaseUrl -> EitherT ServantError IO ())) => Proxy api -> WrappedApi From 8015906b53816d004a59f24d53f3797d3e3e96a3 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 9 Mar 2015 08:37:09 +1100 Subject: [PATCH 34/47] Record accessors for ServantError --- src/Servant/Common/Req.hs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 7405e5cd..d97109c2 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -28,11 +28,27 @@ import System.IO.Unsafe import qualified Network.HTTP.Client as Client data ServantError - = FailureResponse Status MediaType ByteString - | DecodeFailure String MediaType ByteString - | UnsupportedContentType MediaType ByteString - | ConnectionError HttpException - | InvalidContentTypeHeader String + = FailureResponse + { responseStatus :: Status + , responseContentType :: MediaType + , responseBody :: ByteString + } + | DecodeFailure + { decodeError :: String + , responseContentType :: MediaType + , responseBody :: ByteString + } + | UnsupportedContentType + { responseContentType :: MediaType + , responseBody :: ByteString + } + | ConnectionError + { connectionError :: HttpException + } + | InvalidContentTypeHeader + { responseContentTypeHeader :: ByteString + , responseBody :: ByteString + } deriving (Show) data Req = Req @@ -139,7 +155,7 @@ performRequest reqMethod req isWantedStatus reqHost = do ct <- case lookup "Content-Type" $ Client.responseHeaders response of Nothing -> pure $ "application"//"octet-stream" Just t -> case parseAccept t of - Nothing -> left . InvalidContentTypeHeader . cs $ t + Nothing -> left $ InvalidContentTypeHeader (cs t) body Just t' -> pure t' unless (isWantedStatus status_code) $ left $ FailureResponse status ct body From fe6962d0b955cf0b850b0622ce1dd814dcd4d95b Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 9 Mar 2015 08:46:57 +1100 Subject: [PATCH 35/47] Adjust existing tests for change in error type --- test/Servant/ClientSpec.hs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 51abeacc..812d10c4 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -157,9 +157,9 @@ spec = do (Arrow.left show <$> runEitherT (getBody p host)) `shouldReturn` Right p it "Servant.API.QueryParam" $ withServer $ \ host -> do - (Arrow.left show <$> runEitherT (getQueryParam (Just "alice") host)) `shouldReturn` Right alice - Left (FailureResponse s _ _) <- runEitherT (getQueryParam (Just "bob") host) - statusCode s `shouldBe` 400 + Arrow.left show <$> runEitherT (getQueryParam (Just "alice") host) `shouldReturn` Right alice + Left FailureResponse{..} <- runEitherT (getQueryParam (Just "bob") host) + responseStatus `shouldBe` Status 400 "bob not found" it "Servant.API.QueryParam.QueryParams" $ withServer $ \ host -> do (Arrow.left show <$> runEitherT (getQueryParams [] host)) `shouldReturn` Right [] @@ -171,22 +171,20 @@ spec = do it (show flag) $ withServer $ \ host -> do (Arrow.left show <$> runEitherT (getQueryFlag flag host)) `shouldReturn` Right flag -{- it "Servant.API.MatrixParam" $ withServer $ \ host -> do - runEitherT (getMatrixParam (Just "alice") host) `shouldReturn` Right alice - Left result <- runEitherT (getMatrixParam (Just "bob") host) - result `shouldContain` "bob not found" + Arrow.left show <$> runEitherT (getMatrixParam (Just "alice") host) `shouldReturn` Right alice + Left FailureResponse{..} <- runEitherT (getMatrixParam (Just "bob") host) + responseStatus `shouldBe` Status 400 "bob not found" it "Servant.API.MatrixParam.MatrixParams" $ withServer $ \ host -> do - runEitherT (getMatrixParams [] host) `shouldReturn` Right [] - runEitherT (getMatrixParams ["alice", "bob"] host) + Arrow.left show <$> runEitherT (getMatrixParams [] host) `shouldReturn` Right [] + Arrow.left show <$> runEitherT (getMatrixParams ["alice", "bob"] host) `shouldReturn` Right [Person "alice" 0, Person "bob" 1] context "Servant.API.MatrixParam.MatrixFlag" $ forM_ [False, True] $ \ flag -> it (show flag) $ withServer $ \ host -> do - runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag --} + Arrow.left show <$> runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag it "Servant.API.Raw on success" $ withServer $ \ host -> do (Arrow.left show <$> runEitherT (getRawSuccess methodGet host)) @@ -214,8 +212,8 @@ spec = do \ host -> do let getResponse :: BaseUrl -> EitherT ServantError IO () getResponse = client api - Left (FailureResponse status _ _) <- runEitherT (getResponse host) - status `shouldBe` (Status 500 "error message") + Left FailureResponse{..} <- runEitherT (getResponse host) + responseStatus `shouldBe` (Status 500 "error message") mapM_ test $ (WrappedApi (Proxy :: Proxy Delete), "Delete") : (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Delete") : From 23311a8f5dec64b49e2caf50d58fbaa65efc8f3a Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 9 Mar 2015 09:59:40 +1100 Subject: [PATCH 36/47] Some tests for errors --- servant-client.cabal | 1 + test/Servant/ClientSpec.hs | 42 +++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index 77b832b7..b743e1a5 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -71,6 +71,7 @@ test-suite spec , deepseq , either , hspec == 2.* + , http-client , http-media , http-types , network >= 2.6 diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 812d10c4..b843de4c 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -5,7 +5,9 @@ {-# LANGUAGE TypeOperators #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} {-# OPTIONS_GHC -fcontext-stack=25 #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} module Servant.ClientSpec where import Control.Applicative @@ -21,6 +23,7 @@ import Data.Monoid import Data.Proxy import qualified Data.Text as T import GHC.Generics +import Network.HTTP.Client (HttpException(..)) import Network.HTTP.Media import Network.HTTP.Types import Network.Socket @@ -59,6 +62,10 @@ instance FromFormUrlEncoded Person where a <- lookupEither "age" xs return $ Person (T.unpack n) (read $ T.unpack a) +deriving instance Eq ServantError + +instance Eq HttpException where + a == b = show a == show b alice :: Person alice = Person "Alice" 42 @@ -111,6 +118,19 @@ server = serve api ( withServer :: (BaseUrl -> IO a) -> IO a withServer action = withWaiDaemon (return server) action +type FailApi = + "get" :> Get '[FormUrlEncoded] Person +failApi :: Proxy FailApi +failApi = Proxy + +failServer :: Application +failServer = serve failApi ( + return alice + ) + +withFailServer :: (BaseUrl -> IO a) -> IO a +withFailServer action = withWaiDaemon (return failServer) action + getGet :: BaseUrl -> EitherT ServantError IO Person getDelete :: BaseUrl -> EitherT ServantError IO () getCapture :: String -> BaseUrl -> EitherT ServantError IO Person @@ -216,11 +236,27 @@ spec = do responseStatus `shouldBe` (Status 500 "error message") mapM_ test $ (WrappedApi (Proxy :: Proxy Delete), "Delete") : - (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Delete") : - (WrappedApi (Proxy :: Proxy (Post '[JSON] ())), "Delete") : - (WrappedApi (Proxy :: Proxy (Put '[JSON] ())), "Delete") : + (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Get") : + (WrappedApi (Proxy :: Proxy (Post '[JSON] ())), "Post") : + (WrappedApi (Proxy :: Proxy (Put '[JSON] ())), "Put") : [] + context "client returns errors appropriately" $ do + it "reports connection errors" $ do + Right host <- return $ parseBaseUrl "127.0.0.1:987654" + Left (ConnectionError (FailedConnectionException2 "127.0.0.1" 987654 False _)) <- runEitherT (getGet host) + return () + it "reports non-success responses" $ withFailServer $ \ host -> do + Left res <- runEitherT (getDelete host) + case res of + FailureResponse (Status 404 "Not Found") _ _ -> return () + _ -> fail $ "expected 404 response, but got " <> show res + it "reports unsupported content types" $ withFailServer $ \ host -> do + Left res <- runEitherT (getGet host) + case res of + FailureResponse (Status 404 "Not Found") _ _ -> return () + _ -> fail $ "expected 404 response, but got " <> show res + data WrappedApi where WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, HasClient api, Client api ~ (BaseUrl -> EitherT ServantError IO ())) => From 07d84d019c37a2bca97edf5b0ddebc25540c9340 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 9 Mar 2015 14:38:32 +1100 Subject: [PATCH 37/47] Tests for all reported errors --- test/Servant/ClientSpec.hs | 62 +++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index b843de4c..f3fb67c3 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -118,19 +118,6 @@ server = serve api ( withServer :: (BaseUrl -> IO a) -> IO a withServer action = withWaiDaemon (return server) action -type FailApi = - "get" :> Get '[FormUrlEncoded] Person -failApi :: Proxy FailApi -failApi = Proxy - -failServer :: Application -failServer = serve failApi ( - return alice - ) - -withFailServer :: (BaseUrl -> IO a) -> IO a -withFailServer action = withWaiDaemon (return failServer) action - getGet :: BaseUrl -> EitherT ServantError IO Person getDelete :: BaseUrl -> EitherT ServantError IO () getCapture :: String -> BaseUrl -> EitherT ServantError IO Person @@ -161,6 +148,23 @@ getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] :<|> getMultiple) = client api +type FailApi = + "get" :> Raw + :<|> "capture" :> Capture "name" String :> Raw + :<|> "body" :> Raw +failApi :: Proxy FailApi +failApi = Proxy + +failServer :: Application +failServer = serve failApi ( + (\ _request respond -> respond $ responseLBS ok200 [] "") + :<|> (\ _capture _request respond -> respond $ responseLBS ok200 [("content-type", "application/json")] "") + :<|> (\_request respond -> respond $ responseLBS ok200 [("content-type", "fooooo")] "") + ) + +withFailServer :: (BaseUrl -> IO a) -> IO a +withFailServer action = withWaiDaemon (return failServer) action + spec :: Spec spec = do it "Servant.API.Get" $ withServer $ \ host -> do @@ -242,20 +246,36 @@ spec = do [] context "client returns errors appropriately" $ do - it "reports connection errors" $ do - Right host <- return $ parseBaseUrl "127.0.0.1:987654" - Left (ConnectionError (FailedConnectionException2 "127.0.0.1" 987654 False _)) <- runEitherT (getGet host) - return () - it "reports non-success responses" $ withFailServer $ \ host -> do + it "reports FailureResponse" $ withFailServer $ \ host -> do Left res <- runEitherT (getDelete host) case res of FailureResponse (Status 404 "Not Found") _ _ -> return () _ -> fail $ "expected 404 response, but got " <> show res - it "reports unsupported content types" $ withFailServer $ \ host -> do + + it "reports DecodeFailure" $ withFailServer $ \ host -> do + Left res <- runEitherT (getCapture "foo" host) + case res of + DecodeFailure _ ("application/json") _ -> return () + _ -> fail $ "expected DecodeFailure, but got " <> show res + + it "reports ConnectionError" $ do + Right host <- return $ parseBaseUrl "127.0.0.1:987654" Left res <- runEitherT (getGet host) case res of - FailureResponse (Status 404 "Not Found") _ _ -> return () - _ -> fail $ "expected 404 response, but got " <> show res + ConnectionError (FailedConnectionException2 "127.0.0.1" 987654 False _) -> return () + _ -> fail $ "expected ConnectionError, but got " <> show res + + it "reports UnsupportedContentType" $ withFailServer $ \ host -> do + Left res <- runEitherT (getGet host) + case res of + UnsupportedContentType ("application/octet-stream") _ -> return () + _ -> fail $ "expected UnsupportedContentType, but got " <> show res + + it "reports InvalidContentTypeHeader" $ withFailServer $ \ host -> do + Left res <- runEitherT (getBody alice host) + case res of + InvalidContentTypeHeader "fooooo" _ -> return () + _ -> fail $ "expected InvalidContentTypeHeader, but got " <> show res data WrappedApi where WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, From 74b5bc400c60db477ab9bc11cf7993ed07b8cde0 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 9 Mar 2015 17:04:31 +1100 Subject: [PATCH 38/47] Allow more response codes without failing --- src/Servant/Client.hs | 6 +++--- src/Servant/Common/Req.hs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 4479223e..f6b097a8 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -120,7 +120,7 @@ instance HasClient Delete where instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where type Client (Get (ct ': cts) result) = BaseUrl -> EitherT ServantError IO result clientWithRoute Proxy req host = - performRequestCT (Proxy :: Proxy ct) H.methodGet req 200 host + performRequestCT (Proxy :: Proxy ct) H.methodGet req [200] host -- | If you use a 'Header' in one of your endpoints in your API, -- the corresponding querying function will automatically take @@ -167,7 +167,7 @@ instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where type Client (Post (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req uri = - performRequestCT (Proxy :: Proxy ct) H.methodPost req 201 uri + performRequestCT (Proxy :: Proxy ct) H.methodPost req [200,201] uri -- | If you have a 'Put' endpoint in your API, the client -- side querying function that is created when calling 'client' @@ -177,7 +177,7 @@ instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where type Client (Put (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req host = - performRequestCT (Proxy :: Proxy ct) H.methodPut req 200 host + performRequestCT (Proxy :: Proxy ct) H.methodPut req [200,201] host -- | If you use a 'QueryParam' in one of your endpoints in your API, -- the corresponding querying function will automatically take diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index d97109c2..03e6b71b 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -8,7 +8,7 @@ import Control.Monad import Control.Monad.Catch (MonadThrow) import Control.Monad.IO.Class import Control.Monad.Trans.Either -import Data.ByteString.Lazy hiding (pack, filter, map, null) +import Data.ByteString.Lazy hiding (pack, filter, map, null, elem) import Data.IORef import Data.String import Data.String.Conversions @@ -162,11 +162,11 @@ performRequest reqMethod req isWantedStatus reqHost = do return (status_code, body, ct) performRequestCT :: MimeUnrender ct result => - Proxy ct -> Method -> Req -> Int -> BaseUrl -> EitherT ServantError IO result + Proxy ct -> Method -> Req -> [Int] -> BaseUrl -> EitherT ServantError IO result performRequestCT ct reqMethod req wantedStatus reqHost = do let acceptCT = contentType ct (_status, respBody, respCT) <- - performRequest reqMethod (req { reqAccept = [acceptCT] }) (== wantedStatus) reqHost + performRequest reqMethod (req { reqAccept = [acceptCT] }) (`elem` wantedStatus) reqHost unless (matches respCT (acceptCT)) $ left $ UnsupportedContentType respCT respBody either From ecb86274333bf7e776af5eaceb376575e6f4bfa0 Mon Sep 17 00:00:00 2001 From: Timo von Holtz Date: Mon, 16 Mar 2015 11:12:11 +1100 Subject: [PATCH 39/47] Return complete response in Raw endpoint --- servant-client.cabal | 1 + src/Servant/Client.hs | 7 ++++--- src/Servant/Common/Req.hs | 6 +++--- test/Servant/ClientSpec.hs | 31 +++++++++++++++++++++---------- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/servant-client.cabal b/servant-client.cabal index b743e1a5..512b64bd 100644 --- a/servant-client.cabal +++ b/servant-client.cabal @@ -74,6 +74,7 @@ test-suite spec , http-client , http-media , http-types + , HUnit , network >= 2.6 , QuickCheck >= 2.7 , servant >= 0.2.1 diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index f6b097a8..0cff69c2 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -23,6 +23,7 @@ import Data.Proxy import Data.String.Conversions import Data.Text (unpack) import GHC.TypeLits +import Network.HTTP.Client (Response) import Network.HTTP.Media import qualified Network.HTTP.Types as H import Servant.API @@ -411,12 +412,12 @@ instance (KnownSymbol sym, HasClient sublayout) where paramname = cs $ symbolVal (Proxy :: Proxy sym) -- | Pick a 'Method' and specify where the server you want to query is. You get --- back the status code and the response body as a 'ByteString'. +-- back the full `Response`. instance HasClient Raw where - type Client Raw = H.Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) + type Client Raw = H.Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType, Response ByteString) clientWithRoute :: Proxy Raw -> Req -> Client Raw - clientWithRoute Proxy req httpMethod host = + clientWithRoute Proxy req httpMethod host = do performRequest httpMethod req (const True) host -- | If you use a 'ReqBody' in one of your endpoints in your API, diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index 03e6b71b..d87045cc 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -133,7 +133,7 @@ displayHttpRequest :: Method -> String displayHttpRequest httpmethod = "HTTP " ++ cs httpmethod ++ " request" -performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) +performRequest :: Method -> Req -> (Int -> Bool) -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType, Response ByteString) performRequest reqMethod req isWantedStatus reqHost = do partialRequest <- liftIO $ reqToRequest req reqHost @@ -159,13 +159,13 @@ performRequest reqMethod req isWantedStatus reqHost = do Just t' -> pure t' unless (isWantedStatus status_code) $ left $ FailureResponse status ct body - return (status_code, body, ct) + return (status_code, body, ct, response) performRequestCT :: MimeUnrender ct result => Proxy ct -> Method -> Req -> [Int] -> BaseUrl -> EitherT ServantError IO result performRequestCT ct reqMethod req wantedStatus reqHost = do let acceptCT = contentType ct - (_status, respBody, respCT) <- + (_status, respBody, respCT, _response) <- performRequest reqMethod (req { reqAccept = [acceptCT] }) (`elem` wantedStatus) reqHost unless (matches respCT (acceptCT)) $ left $ UnsupportedContentType respCT respBody diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index f3fb67c3..7b1645c0 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -23,14 +23,15 @@ import Data.Monoid import Data.Proxy import qualified Data.Text as T import GHC.Generics -import Network.HTTP.Client (HttpException(..)) +import qualified Network.HTTP.Client as C import Network.HTTP.Media import Network.HTTP.Types import Network.Socket -import Network.Wai +import Network.Wai hiding (Response) import Network.Wai.Handler.Warp import Test.Hspec import Test.Hspec.QuickCheck +import Test.HUnit import Test.QuickCheck import Servant.API @@ -64,7 +65,7 @@ instance FromFormUrlEncoded Person where deriving instance Eq ServantError -instance Eq HttpException where +instance Eq C.HttpException where a == b = show a == show b alice :: Person @@ -128,8 +129,8 @@ getQueryFlag :: Bool -> BaseUrl -> EitherT ServantError IO Bool getMatrixParam :: Maybe String -> BaseUrl -> EitherT ServantError IO Person getMatrixParams :: [String] -> BaseUrl -> EitherT ServantError IO [Person] getMatrixFlag :: Bool -> BaseUrl -> EitherT ServantError IO Bool -getRawSuccess :: Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) -getRawFailure :: Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType) +getRawSuccess :: Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType, C.Response ByteString) +getRawFailure :: Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType, C.Response ByteString) getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] -> BaseUrl -> EitherT ServantError IO (String, Maybe Int, Bool, [(String, [Rational])]) @@ -211,12 +212,22 @@ spec = do Arrow.left show <$> runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag it "Servant.API.Raw on success" $ withServer $ \ host -> do - (Arrow.left show <$> runEitherT (getRawSuccess methodGet host)) - `shouldReturn` Right (200, "rawSuccess", "application"//"octet-stream") + res <- runEitherT (getRawSuccess methodGet host) + case res of + Left e -> assertFailure $ show e + Right (code, body, ct, response) -> do + (code, body, ct) `shouldBe` (200, "rawSuccess", "application"//"octet-stream") + C.responseBody response `shouldBe` body + C.responseStatus response `shouldBe` ok200 it "Servant.API.Raw on failure" $ withServer $ \ host -> do - (Arrow.left show <$> runEitherT (getRawFailure methodGet host)) - `shouldReturn` Right (400, "rawFailure", "application"//"octet-stream") + res <- runEitherT (getRawFailure methodGet host) + case res of + Left e -> assertFailure $ show e + Right (code, body, ct, response) -> do + (code, body, ct) `shouldBe` (400, "rawFailure", "application"//"octet-stream") + C.responseBody response `shouldBe` body + C.responseStatus response `shouldBe` badRequest400 modifyMaxSuccess (const 20) $ do it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ @@ -262,7 +273,7 @@ spec = do Right host <- return $ parseBaseUrl "127.0.0.1:987654" Left res <- runEitherT (getGet host) case res of - ConnectionError (FailedConnectionException2 "127.0.0.1" 987654 False _) -> return () + ConnectionError (C.FailedConnectionException2 "127.0.0.1" 987654 False _) -> return () _ -> fail $ "expected ConnectionError, but got " <> show res it "reports UnsupportedContentType" $ withFailServer $ \ host -> do From b0056294cca819567425aea48636f4a5d0a0978e Mon Sep 17 00:00:00 2001 From: Mateusz Kowalczyk Date: Wed, 25 Mar 2015 15:21:05 +0000 Subject: [PATCH 40/47] Add .ghci file --- .ghci | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ghci diff --git a/.ghci b/.ghci new file mode 100644 index 00000000..40370b11 --- /dev/null +++ b/.ghci @@ -0,0 +1 @@ +:set -isrc \ No newline at end of file From d21f0bbbfc8eb998d6e8609290272dca4f30ad55 Mon Sep 17 00:00:00 2001 From: Mateusz Kowalczyk Date: Wed, 25 Mar 2015 15:34:58 +0000 Subject: [PATCH 41/47] Accept wider range of return codes DELETE 201 doesn't make sense I think we can get away with just 204 without 205 and 206 GET 206 should be handled by the lib itself (?) so we only add 203 Closes #20 --- src/Servant/Client.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index f6b097a8..03f8d32e 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -111,7 +111,7 @@ instance HasClient Delete where type Client Delete = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = - void $ performRequest H.methodDelete req (== 204) host + void $ performRequest H.methodDelete req (`elem` [200, 202, 204]) host -- | If you have a 'Get' endpoint in your API, the client -- side querying function that is created when calling 'client' @@ -120,7 +120,7 @@ instance HasClient Delete where instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where type Client (Get (ct ': cts) result) = BaseUrl -> EitherT ServantError IO result clientWithRoute Proxy req host = - performRequestCT (Proxy :: Proxy ct) H.methodGet req [200] host + performRequestCT (Proxy :: Proxy ct) H.methodGet req [200, 203] host -- | If you use a 'Header' in one of your endpoints in your API, -- the corresponding querying function will automatically take From 9a4cfd64c52732f651e868713e63961472016ae6 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Thu, 26 Mar 2015 12:56:54 +0100 Subject: [PATCH 42/47] changelog entry for Raw-client-returning-whole-response commit --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe8131d..2cadae97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added a lot of tests * Support multiple concurrent threads * Use `ServantError` to report Errors instead of `String` +* Make the clients for `Raw` endpoints return the whole `Response` value (to be able to access response headers for example) 0.2.2 ----- From 3802ef5447bcb2c22ba83c880ded4774886c6606 Mon Sep 17 00:00:00 2001 From: "Julian K. Arni" Date: Thu, 26 Mar 2015 14:34:38 +0100 Subject: [PATCH 43/47] Add patch method, and make () response expect no content --- CHANGELOG.md | 2 ++ src/Servant/Client.hs | 41 ++++++++++++++++++++++++++++++++++++++- src/Servant/Common/Req.hs | 4 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cadae97..df677a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * Support multiple concurrent threads * Use `ServantError` to report Errors instead of `String` * Make the clients for `Raw` endpoints return the whole `Response` value (to be able to access response headers for example) +* Support for PATCH +* Make () instances expect No Content status code, and not try to decode body. 0.2.2 ----- diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 955b96f0..7ef664d0 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -4,6 +4,7 @@ {-# LANGUAGE TypeOperators #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE OverlappingInstances #-} {-# LANGUAGE ScopedTypeVariables #-} -- | This module provides 'client' which can automatically generate -- querying functions for each endpoint just from the type representing your @@ -123,6 +124,13 @@ instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodGet req [200, 203] host +-- | If you have a 'Get xs ()' endpoint, the client expects a 204 No Content +-- HTTP header. +instance HasClient (Get (ct ': cts) ()) where + type Client (Get (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + clientWithRoute Proxy req host = + performRequestNoBody H.methodGet req [204] host + -- | If you use a 'Header' in one of your endpoints in your API, -- the corresponding querying function will automatically take -- an additional argument of the type specified by your 'Header', @@ -170,6 +178,13 @@ instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where clientWithRoute Proxy req uri = performRequestCT (Proxy :: Proxy ct) H.methodPost req [200,201] uri +-- | If you have a 'Post xs ()' endpoint, the client expects a 204 No Content +-- HTTP header. +instance HasClient (Post (ct ': cts) ()) where + type Client (Post (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + clientWithRoute Proxy req host = + void $ performRequestNoBody H.methodPost req [204] host + -- | If you have a 'Put' endpoint in your API, the client -- side querying function that is created when calling 'client' -- will just require an argument that specifies the scheme, host @@ -180,6 +195,30 @@ instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodPut req [200,201] host +-- | If you have a 'Put xs ()' endpoint, the client expects a 204 No Content +-- HTTP header. +instance HasClient (Put (ct ': cts) ()) where + type Client (Put (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + clientWithRoute Proxy req host = + void $ performRequestNoBody H.methodPut req [204] host + +-- | If you have a 'Patch' endpoint in your API, the client +-- side querying function that is created when calling 'client' +-- will just require an argument that specifies the scheme, host +-- and port to send the request to. +instance (MimeUnrender ct a) => HasClient (Patch (ct ': cts) a) where + type Client (Patch (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a + + clientWithRoute Proxy req host = + performRequestCT (Proxy :: Proxy ct) H.methodPatch req [200,201] host + +-- | If you have a 'Patch xs ()' endpoint, the client expects a 204 No Content +-- HTTP header. +instance HasClient (Patch (ct ': cts) ()) where + type Client (Patch (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + clientWithRoute Proxy req host = + void $ performRequestNoBody H.methodPatch req [204] host + -- | If you use a 'QueryParam' in one of your endpoints in your API, -- the corresponding querying function will automatically take -- an additional argument of the type specified by your 'QueryParam', @@ -222,7 +261,7 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) -- | If you use a 'QueryParams' in one of your endpoints in your API, -- the corresponding querying function will automatically take --- an additional argument, a list of values of the type specified +-- an additional argument, a list of values of the type specified -- by your 'QueryParams'. -- -- If you give an empty list, nothing will be added to the query string. diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index d87045cc..a0ffbc14 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -174,6 +174,10 @@ performRequestCT ct reqMethod req wantedStatus reqHost = do return (fromByteString ct respBody) +performRequestNoBody :: Method -> Req -> [Int] -> BaseUrl -> EitherT ServantError IO () +performRequestNoBody reqMethod req wantedStatus reqHost = do + _ <- performRequest reqMethod req (`elem` wantedStatus) reqHost + return () catchHttpException :: IO a -> IO (Either HttpException a) catchHttpException action = From 34f1715666cbdc694bdbf0c7564ecc4f1f61b8a0 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Mon, 9 Mar 2015 21:50:30 +0100 Subject: [PATCH 44/47] canonicalize api type before generating client functions, to flatten out all the client functions, distributing arguments properly: Client (a :> (b :<|> c)) = Client (a :> b) :<|> Client (a :> c) --- src/Servant/Client.hs | 63 ++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 7ef664d0..2c619f0d 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -2,6 +2,7 @@ {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} +{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverlappingInstances #-} @@ -45,15 +46,17 @@ import Servant.Common.Req -- > getAllBooks :: BaseUrl -> EitherT String IO [Book] -- > postNewBook :: Book -> BaseUrl -> EitherT String IO Book -- > (getAllBooks :<|> postNewBook) = client myApi -client :: HasClient layout => Proxy layout -> Client layout -client p = clientWithRoute p defReq +client :: HasClient (Canonicalize layout) => Proxy layout -> Client layout +client p = clientWithRoute (canonicalize p) defReq -- | This class lets us define how each API combinator -- influences the creation of an HTTP request. It's mostly -- an internal class, you can just use 'client'. class HasClient layout where - type Client layout :: * - clientWithRoute :: Proxy layout -> Req -> Client layout + type Client' layout :: * + clientWithRoute :: Proxy layout -> Req -> Client' layout + +type Client layout = Client' (Canonicalize layout) -- | A client querying function for @a ':<|>' b@ will actually hand you -- one function for querying @a@ and another one for querying @b@, @@ -69,7 +72,7 @@ class HasClient layout where -- > postNewBook :: Book -> BaseUrl -> EitherT String IO Book -- > (getAllBooks :<|> postNewBook) = client myApi instance (HasClient a, HasClient b) => HasClient (a :<|> b) where - type Client (a :<|> b) = Client a :<|> Client b + type Client' (a :<|> b) = Client' a :<|> Client' b clientWithRoute Proxy req = clientWithRoute (Proxy :: Proxy a) req :<|> clientWithRoute (Proxy :: Proxy b) req @@ -96,8 +99,8 @@ instance (HasClient a, HasClient b) => HasClient (a :<|> b) where instance (KnownSymbol capture, ToText a, HasClient sublayout) => HasClient (Capture capture a :> sublayout) where - type Client (Capture capture a :> sublayout) = - a -> Client sublayout + type Client' (Capture capture a :> sublayout) = + a -> Client' sublayout clientWithRoute Proxy req val = clientWithRoute (Proxy :: Proxy sublayout) $ @@ -110,7 +113,7 @@ instance (KnownSymbol capture, ToText a, HasClient sublayout) -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance HasClient Delete where - type Client Delete = BaseUrl -> EitherT ServantError IO () + type Client' Delete = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = void $ performRequest H.methodDelete req (`elem` [200, 202, 204]) host @@ -120,7 +123,7 @@ instance HasClient Delete where -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where - type Client (Get (ct ': cts) result) = BaseUrl -> EitherT ServantError IO result + type Client' (Get (ct ': cts) result) = BaseUrl -> EitherT ServantError IO result clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodGet req [200, 203] host @@ -159,8 +162,8 @@ instance HasClient (Get (ct ': cts) ()) where instance (KnownSymbol sym, ToText a, HasClient sublayout) => HasClient (Header sym a :> sublayout) where - type Client (Header sym a :> sublayout) = - Maybe a -> Client sublayout + type Client' (Header sym a :> sublayout) = + Maybe a -> Client' sublayout clientWithRoute Proxy req mval = clientWithRoute (Proxy :: Proxy sublayout) $ @@ -173,7 +176,7 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where - type Client (Post (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a + type Client' (Post (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req uri = performRequestCT (Proxy :: Proxy ct) H.methodPost req [200,201] uri @@ -190,7 +193,7 @@ instance HasClient (Post (ct ': cts) ()) where -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where - type Client (Put (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a + type Client' (Put (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodPut req [200,201] host @@ -247,8 +250,8 @@ instance HasClient (Patch (ct ': cts) ()) where instance (KnownSymbol sym, ToText a, HasClient sublayout) => HasClient (QueryParam sym a :> sublayout) where - type Client (QueryParam sym a :> sublayout) = - Maybe a -> Client sublayout + type Client' (QueryParam sym a :> sublayout) = + Maybe a -> Client' sublayout -- if mparam = Nothing, we don't add it to the query string clientWithRoute Proxy req mparam = @@ -289,8 +292,8 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) instance (KnownSymbol sym, ToText a, HasClient sublayout) => HasClient (QueryParams sym a :> sublayout) where - type Client (QueryParams sym a :> sublayout) = - [a] -> Client sublayout + type Client' (QueryParams sym a :> sublayout) = + [a] -> Client' sublayout clientWithRoute Proxy req paramlist = clientWithRoute (Proxy :: Proxy sublayout) $ @@ -324,8 +327,8 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) instance (KnownSymbol sym, HasClient sublayout) => HasClient (QueryFlag sym :> sublayout) where - type Client (QueryFlag sym :> sublayout) = - Bool -> Client sublayout + type Client' (QueryFlag sym :> sublayout) = + Bool -> Client' sublayout clientWithRoute Proxy req flag = clientWithRoute (Proxy :: Proxy sublayout) $ @@ -363,8 +366,8 @@ instance (KnownSymbol sym, HasClient sublayout) instance (KnownSymbol sym, ToText a, HasClient sublayout) => HasClient (MatrixParam sym a :> sublayout) where - type Client (MatrixParam sym a :> sublayout) = - Maybe a -> Client sublayout + type Client' (MatrixParam sym a :> sublayout) = + Maybe a -> Client' sublayout -- if mparam = Nothing, we don't add it to the query string clientWithRoute Proxy req mparam = @@ -404,8 +407,8 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) instance (KnownSymbol sym, ToText a, HasClient sublayout) => HasClient (MatrixParams sym a :> sublayout) where - type Client (MatrixParams sym a :> sublayout) = - [a] -> Client sublayout + type Client' (MatrixParams sym a :> sublayout) = + [a] -> Client' sublayout clientWithRoute Proxy req paramlist = clientWithRoute (Proxy :: Proxy sublayout) $ @@ -439,8 +442,8 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) instance (KnownSymbol sym, HasClient sublayout) => HasClient (MatrixFlag sym :> sublayout) where - type Client (MatrixFlag sym :> sublayout) = - Bool -> Client sublayout + type Client' (MatrixFlag sym :> sublayout) = + Bool -> Client' sublayout clientWithRoute Proxy req flag = clientWithRoute (Proxy :: Proxy sublayout) $ @@ -453,9 +456,9 @@ instance (KnownSymbol sym, HasClient sublayout) -- | Pick a 'Method' and specify where the server you want to query is. You get -- back the full `Response`. instance HasClient Raw where - type Client Raw = H.Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType, Response ByteString) + type Client' Raw = H.Method -> BaseUrl -> EitherT ServantError IO (Int, ByteString, MediaType, Response ByteString) - clientWithRoute :: Proxy Raw -> Req -> Client Raw + clientWithRoute :: Proxy Raw -> Req -> Client' Raw clientWithRoute Proxy req httpMethod host = do performRequest httpMethod req (const True) host @@ -480,8 +483,8 @@ instance HasClient Raw where instance (MimeRender ct a, HasClient sublayout) => HasClient (ReqBody (ct ': cts) a :> sublayout) where - type Client (ReqBody (ct ': cts) a :> sublayout) = - a -> Client sublayout + type Client' (ReqBody (ct ': cts) a :> sublayout) = + a -> Client' sublayout clientWithRoute Proxy req body = clientWithRoute (Proxy :: Proxy sublayout) $ do @@ -490,7 +493,7 @@ instance (MimeRender ct a, HasClient sublayout) -- | Make the querying function append @path@ to the request path. instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where - type Client (path :> sublayout) = Client sublayout + type Client' (path :> sublayout) = Client' sublayout clientWithRoute Proxy req = clientWithRoute (Proxy :: Proxy sublayout) $ From eae2f5f282fb92a7f845c1fcd36db21cb0505bf4 Mon Sep 17 00:00:00 2001 From: Alp Mestanogullari Date: Wed, 11 Mar 2015 21:38:36 +0100 Subject: [PATCH 45/47] add a changelog entry for canonicalize --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df677a29..9e7f1090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Make the clients for `Raw` endpoints return the whole `Response` value (to be able to access response headers for example) * Support for PATCH * Make () instances expect No Content status code, and not try to decode body. +* `Canonicalize` API types before generating client functions for them 0.2.2 ----- From d7fcf2b19b2af04ef5104b433f87837722c826fe Mon Sep 17 00:00:00 2001 From: "Julian K. Arni" Date: Sun, 19 Apr 2015 18:31:23 +0200 Subject: [PATCH 46/47] Fix missing canonicalize changes --- src/Servant/Client.hs | 15 ++++---- src/Servant/Common/Req.hs | 2 +- test/Servant/ClientSpec.hs | 70 +++++++++++++++++++------------------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/src/Servant/Client.hs b/src/Servant/Client.hs index 2c619f0d..c50b5471 100644 --- a/src/Servant/Client.hs +++ b/src/Servant/Client.hs @@ -13,6 +13,7 @@ module Servant.Client ( client , HasClient(..) + , Client , ServantError(..) , module Servant.Common.BaseUrl ) where @@ -130,7 +131,7 @@ instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where -- | If you have a 'Get xs ()' endpoint, the client expects a 204 No Content -- HTTP header. instance HasClient (Get (ct ': cts) ()) where - type Client (Get (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + type Client' (Get (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = performRequestNoBody H.methodGet req [204] host @@ -167,7 +168,7 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout) clientWithRoute Proxy req mval = clientWithRoute (Proxy :: Proxy sublayout) $ - maybe req (\value -> addHeader hname value req) mval + maybe req (\value -> Servant.Common.Req.addHeader hname value req) mval where hname = symbolVal (Proxy :: Proxy sym) @@ -184,7 +185,7 @@ instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where -- | If you have a 'Post xs ()' endpoint, the client expects a 204 No Content -- HTTP header. instance HasClient (Post (ct ': cts) ()) where - type Client (Post (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + type Client' (Post (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = void $ performRequestNoBody H.methodPost req [204] host @@ -201,7 +202,7 @@ instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where -- | If you have a 'Put xs ()' endpoint, the client expects a 204 No Content -- HTTP header. instance HasClient (Put (ct ': cts) ()) where - type Client (Put (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + type Client' (Put (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = void $ performRequestNoBody H.methodPut req [204] host @@ -210,7 +211,7 @@ instance HasClient (Put (ct ': cts) ()) where -- will just require an argument that specifies the scheme, host -- and port to send the request to. instance (MimeUnrender ct a) => HasClient (Patch (ct ': cts) a) where - type Client (Patch (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a + type Client' (Patch (ct ': cts) a) = BaseUrl -> EitherT ServantError IO a clientWithRoute Proxy req host = performRequestCT (Proxy :: Proxy ct) H.methodPatch req [200,201] host @@ -218,7 +219,7 @@ instance (MimeUnrender ct a) => HasClient (Patch (ct ': cts) a) where -- | If you have a 'Patch xs ()' endpoint, the client expects a 204 No Content -- HTTP header. instance HasClient (Patch (ct ': cts) ()) where - type Client (Patch (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () + type Client' (Patch (ct ': cts) ()) = BaseUrl -> EitherT ServantError IO () clientWithRoute Proxy req host = void $ performRequestNoBody H.methodPatch req [204] host @@ -489,7 +490,7 @@ instance (MimeRender ct a, HasClient sublayout) clientWithRoute Proxy req body = clientWithRoute (Proxy :: Proxy sublayout) $ do let ctProxy = Proxy :: Proxy ct - setRQBody (toByteString ctProxy body) (contentType ctProxy) req + setRQBody (mimeRender ctProxy body) (contentType ctProxy) req -- | Make the querying function append @path@ to the request path. instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where diff --git a/src/Servant/Common/Req.hs b/src/Servant/Common/Req.hs index a0ffbc14..60c53eb8 100644 --- a/src/Servant/Common/Req.hs +++ b/src/Servant/Common/Req.hs @@ -172,7 +172,7 @@ performRequestCT ct reqMethod req wantedStatus reqHost = do either (left . (\s -> DecodeFailure s respCT respBody)) return - (fromByteString ct respBody) + (mimeUnrender ct respBody) performRequestNoBody :: Method -> Req -> [Int] -> BaseUrl -> EitherT ServantError IO () performRequestNoBody reqMethod req wantedStatus reqHost = do diff --git a/test/Servant/ClientSpec.hs b/test/Servant/ClientSpec.hs index 7b1645c0..ff043ab1 100644 --- a/test/Servant/ClientSpec.hs +++ b/test/Servant/ClientSpec.hs @@ -1,42 +1,42 @@ -{-# LANGUAGE GADTs #-} -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE ViewPatterns #-} -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE TypeOperators #-} -{-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -fcontext-stack=25 #-} {-# OPTIONS_GHC -fno-warn-orphans #-} module Servant.ClientSpec where -import Control.Applicative -import qualified Control.Arrow as Arrow -import Control.Concurrent -import Control.Exception -import Control.Monad.Trans.Either -import Data.Aeson -import Data.ByteString.Lazy (ByteString) -import Data.Char -import Data.Foldable (forM_) -import Data.Monoid -import Data.Proxy -import qualified Data.Text as T -import GHC.Generics -import qualified Network.HTTP.Client as C -import Network.HTTP.Media -import Network.HTTP.Types -import Network.Socket -import Network.Wai hiding (Response) -import Network.Wai.Handler.Warp -import Test.Hspec -import Test.Hspec.QuickCheck -import Test.HUnit -import Test.QuickCheck +import Control.Applicative +import qualified Control.Arrow as Arrow +import Control.Concurrent +import Control.Exception +import Control.Monad.Trans.Either +import Data.Aeson +import Data.ByteString.Lazy (ByteString) +import Data.Char +import Data.Foldable (forM_) +import Data.Monoid +import Data.Proxy +import qualified Data.Text as T +import GHC.Generics +import qualified Network.HTTP.Client as C +import Network.HTTP.Media +import Network.HTTP.Types +import Network.Socket +import Network.Wai hiding (Response) +import Network.Wai.Handler.Warp +import Test.Hspec +import Test.Hspec.QuickCheck +import Test.HUnit +import Test.QuickCheck -import Servant.API -import Servant.Client -import Servant.Server +import Servant.API +import Servant.Client +import Servant.Server -- * test data types @@ -289,8 +289,8 @@ spec = do _ -> fail $ "expected InvalidContentTypeHeader, but got " <> show res data WrappedApi where - WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, - HasClient api, Client api ~ (BaseUrl -> EitherT ServantError IO ())) => + WrappedApi :: (HasServer (Canonicalize api), Server api ~ EitherT (Int, String) IO a, + HasClient (Canonicalize api), Client api ~ (BaseUrl -> EitherT ServantError IO ())) => Proxy api -> WrappedApi From 45f35852f80ee86fd6f42bd6832fefb6ec6dbcd4 Mon Sep 17 00:00:00 2001 From: "Julian K. Arni" Date: Mon, 20 Apr 2015 11:15:58 +0200 Subject: [PATCH 47/47] prepare merge --- .ghci | 1 - .gitignore | 17 ------- .travis.yml | 51 ------------------- CHANGELOG.md => servant-client/CHANGELOG.md | 0 LICENSE => servant-client/LICENSE | 0 README.md => servant-client/README.md | 0 Setup.hs => servant-client/Setup.hs | 0 docs.sh => servant-client/docs.sh | 0 .../servant-client.cabal | 0 {src => servant-client/src}/Servant/Client.hs | 0 .../src}/Servant/Common/BaseUrl.hs | 0 .../src}/Servant/Common/Req.hs | 0 .../test}/Servant/ClientSpec.hs | 0 .../test}/Servant/Common/BaseUrlSpec.hs | 0 {test => servant-client/test}/Spec.hs | 0 15 files changed, 69 deletions(-) delete mode 100644 .ghci delete mode 100644 .gitignore delete mode 100644 .travis.yml rename CHANGELOG.md => servant-client/CHANGELOG.md (100%) rename LICENSE => servant-client/LICENSE (100%) rename README.md => servant-client/README.md (100%) rename Setup.hs => servant-client/Setup.hs (100%) rename docs.sh => servant-client/docs.sh (100%) rename servant-client.cabal => servant-client/servant-client.cabal (100%) rename {src => servant-client/src}/Servant/Client.hs (100%) rename {src => servant-client/src}/Servant/Common/BaseUrl.hs (100%) rename {src => servant-client/src}/Servant/Common/Req.hs (100%) rename {test => servant-client/test}/Servant/ClientSpec.hs (100%) rename {test => servant-client/test}/Servant/Common/BaseUrlSpec.hs (100%) rename {test => servant-client/test}/Spec.hs (100%) diff --git a/.ghci b/.ghci deleted file mode 100644 index 40370b11..00000000 --- a/.ghci +++ /dev/null @@ -1 +0,0 @@ -:set -isrc \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 0855a79b..00000000 --- a/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -dist -cabal-dev -*.o -*.hi -*.chi -*.chs.h -*.dyn_o -*.dyn_hi -.virtualenv -.hpc -.hsenv -.cabal-sandbox/ -cabal.sandbox.config -cabal.config -*.prof -*.aux -*.hp diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 383299db..00000000 --- a/.travis.yml +++ /dev/null @@ -1,51 +0,0 @@ -language: haskell - -env: -- GHCVER=7.8.3 - -before_install: - - | - if [ $GHCVER = `ghc --numeric-version` ]; then - travis/cabal-apt-install --enable-tests $MODE - export CABAL=cabal - else - travis_retry sudo add-apt-repository -y ppa:hvr/ghc - travis_retry sudo apt-get update - travis_retry sudo apt-get install cabal-install-1.18 ghc-$GHCVER happy - export CABAL=cabal-1.18 - export PATH=/opt/ghc/$GHCVER/bin:$PATH - fi - - $CABAL update - - | - if [ $GHCVER = "head" ] || [ $GHCVER = "7.8.3" ]; then - $CABAL install happy alex - export PATH=$HOME/.cabal/bin:$PATH - fi - - git clone https://github.com/haskell-servant/servant.git - - git clone https://github.com/haskell-servant/servant-server.git - - cabal sandbox init - - cabal sandbox add-source servant - - cabal sandbox add-source servant-server - -install: - - cabal install --only-dependencies --enable-tests - -script: - - cabal configure --enable-tests --enable-library-coverage - - cabal build && cabal test - - cabal sdist - -after_script: - - cabal install hpc-coveralls - - hpc-coveralls --exclude-dir=test spec - -notifications: - irc: - channels: - - "irc.freenode.org#servant" - template: - - "%{repository}#%{build_number} - %{commit} on %{branch} by %{author}: %{message}" - - "Build details: %{build_url} - Change view: %{compare_url}" - skip_join: true - on_success: change - on_failure: always diff --git a/CHANGELOG.md b/servant-client/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to servant-client/CHANGELOG.md diff --git a/LICENSE b/servant-client/LICENSE similarity index 100% rename from LICENSE rename to servant-client/LICENSE diff --git a/README.md b/servant-client/README.md similarity index 100% rename from README.md rename to servant-client/README.md diff --git a/Setup.hs b/servant-client/Setup.hs similarity index 100% rename from Setup.hs rename to servant-client/Setup.hs diff --git a/docs.sh b/servant-client/docs.sh similarity index 100% rename from docs.sh rename to servant-client/docs.sh diff --git a/servant-client.cabal b/servant-client/servant-client.cabal similarity index 100% rename from servant-client.cabal rename to servant-client/servant-client.cabal diff --git a/src/Servant/Client.hs b/servant-client/src/Servant/Client.hs similarity index 100% rename from src/Servant/Client.hs rename to servant-client/src/Servant/Client.hs diff --git a/src/Servant/Common/BaseUrl.hs b/servant-client/src/Servant/Common/BaseUrl.hs similarity index 100% rename from src/Servant/Common/BaseUrl.hs rename to servant-client/src/Servant/Common/BaseUrl.hs diff --git a/src/Servant/Common/Req.hs b/servant-client/src/Servant/Common/Req.hs similarity index 100% rename from src/Servant/Common/Req.hs rename to servant-client/src/Servant/Common/Req.hs diff --git a/test/Servant/ClientSpec.hs b/servant-client/test/Servant/ClientSpec.hs similarity index 100% rename from test/Servant/ClientSpec.hs rename to servant-client/test/Servant/ClientSpec.hs diff --git a/test/Servant/Common/BaseUrlSpec.hs b/servant-client/test/Servant/Common/BaseUrlSpec.hs similarity index 100% rename from test/Servant/Common/BaseUrlSpec.hs rename to servant-client/test/Servant/Common/BaseUrlSpec.hs diff --git a/test/Spec.hs b/servant-client/test/Spec.hs similarity index 100% rename from test/Spec.hs rename to servant-client/test/Spec.hs