Merge pull request #11 from anchor/content-types

Content types
This commit is contained in:
Julian Arni 2015-02-25 13:25:10 +01:00
commit 33279c525d
4 changed files with 109 additions and 74 deletions

View file

@ -45,6 +45,7 @@ library
, exceptions , exceptions
, http-client , http-client
, http-client-tls , http-client-tls
, http-media
, http-types , http-types
, network-uri >= 2.6 , network-uri >= 2.6
, safe , safe
@ -70,11 +71,13 @@ test-suite spec
, deepseq , deepseq
, either , either
, hspec == 2.* , hspec == 2.*
, http-media
, http-types , http-types
, network >= 2.6 , network >= 2.6
, QuickCheck >= 2.7 , QuickCheck >= 2.7
, servant >= 0.2.1 , servant >= 0.2.1
, servant-client , servant-client
, servant-server >= 0.2.1 , servant-server >= 0.2.1
, text
, wai , wai
, warp , warp

View file

@ -1,3 +1,4 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE InstanceSigs #-} {-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
@ -22,8 +23,10 @@ import Data.Proxy
import Data.String.Conversions import Data.String.Conversions
import Data.Text (unpack) import Data.Text (unpack)
import GHC.TypeLits import GHC.TypeLits
import Network.HTTP.Media
import qualified Network.HTTP.Types as H import qualified Network.HTTP.Types as H
import Servant.API import Servant.API
import Servant.API.ContentTypes
import Servant.Common.BaseUrl import Servant.Common.BaseUrl
import Servant.Common.Req import Servant.Common.Req
import Servant.Common.Text import Servant.Common.Text
@ -115,10 +118,10 @@ instance HasClient Delete where
-- side querying function that is created when calling 'client' -- side querying function that is created when calling 'client'
-- will just require an argument that specifies the scheme, host -- will just require an argument that specifies the scheme, host
-- and port to send the request to. -- and port to send the request to.
instance FromJSON result => HasClient (Get result) where instance (MimeUnrender ct result) => HasClient (Get (ct ': cts) result) where
type Client (Get result) = BaseUrl -> EitherT String IO result type Client (Get (ct ': cts) result) = BaseUrl -> EitherT String IO result
clientWithRoute Proxy req host = 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, -- | If you use a 'Header' in one of your endpoints in your API,
-- the corresponding querying function will automatically take -- the corresponding querying function will automatically take
@ -161,21 +164,21 @@ instance (KnownSymbol sym, ToText a, HasClient sublayout)
-- side querying function that is created when calling 'client' -- side querying function that is created when calling 'client'
-- will just require an argument that specifies the scheme, host -- will just require an argument that specifies the scheme, host
-- and port to send the request to. -- and port to send the request to.
instance FromJSON a => HasClient (Post a) where instance (MimeUnrender ct a) => HasClient (Post (ct ': cts) a) where
type Client (Post a) = BaseUrl -> EitherT String IO a type Client (Post (ct ': cts) a) = BaseUrl -> EitherT String IO a
clientWithRoute Proxy req uri = 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 -- | If you have a 'Put' endpoint in your API, the client
-- side querying function that is created when calling 'client' -- side querying function that is created when calling 'client'
-- will just require an argument that specifies the scheme, host -- will just require an argument that specifies the scheme, host
-- and port to send the request to. -- and port to send the request to.
instance FromJSON a => HasClient (Put a) where instance (MimeUnrender ct a) => HasClient (Put (ct ': cts) a) where
type Client (Put a) = BaseUrl -> EitherT String IO a type Client (Put (ct ': cts) a) = BaseUrl -> EitherT String IO a
clientWithRoute Proxy req host = 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, -- | If you use a 'QueryParam' in one of your endpoints in your API,
-- the corresponding querying function will automatically take -- the corresponding querying function will automatically take
@ -411,7 +414,7 @@ instance (KnownSymbol sym, HasClient sublayout)
-- | Pick a 'Method' and specify where the server you want to query is. You get -- | 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 status code and the response body as a 'ByteString'.
instance HasClient Raw where 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 Raw -> Req -> Client Raw
clientWithRoute Proxy req httpMethod host = clientWithRoute Proxy req httpMethod host =
@ -435,15 +438,16 @@ instance HasClient Raw where
-- > addBook :: Book -> BaseUrl -> EitherT String IO Book -- > addBook :: Book -> BaseUrl -> EitherT String IO Book
-- > addBook = client myApi -- > addBook = client myApi
-- > -- then you can just use "addBook" to query that endpoint -- > -- then you can just use "addBook" to query that endpoint
instance (ToJSON a, HasClient sublayout) instance (MimeRender ct 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 a -> Client sublayout
clientWithRoute Proxy req body = clientWithRoute Proxy req body =
clientWithRoute (Proxy :: Proxy sublayout) $ clientWithRoute (Proxy :: Proxy sublayout) $ do
setRQBody (encode body) req let ctProxy = Proxy :: Proxy ct
setRQBody (toByteString ctProxy body) (contentType ctProxy) req
-- | Make the querying function append @path@ to the request path. -- | Make the querying function append @path@ to the request path.
instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where instance (KnownSymbol path, HasClient sublayout) => HasClient (path :> sublayout) where

View file

@ -9,19 +9,18 @@ import Control.Monad
import Control.Monad.Catch (MonadThrow) import Control.Monad.Catch (MonadThrow)
import Control.Monad.IO.Class import Control.Monad.IO.Class
import Control.Monad.Trans.Either import Control.Monad.Trans.Either
import Data.Aeson import Data.ByteString.Lazy hiding (pack, filter, map, null)
import Data.Aeson.Parser
import Data.Aeson.Types
import Data.Attoparsec.ByteString
import Data.ByteString.Lazy hiding (pack)
import Data.String import Data.String
import Data.String.Conversions import Data.String.Conversions
import Data.Text import Data.Proxy
import Data.Text (Text)
import Data.Text.Encoding import Data.Text.Encoding
import Network.HTTP.Client import Network.HTTP.Client hiding (Proxy)
import Network.HTTP.Client.TLS import Network.HTTP.Client.TLS
import Network.HTTP.Media
import Network.HTTP.Types import Network.HTTP.Types
import Network.URI import Network.URI
import Servant.API.ContentTypes
import Servant.Common.BaseUrl import Servant.Common.BaseUrl
import Servant.Common.Text import Servant.Common.Text
import System.IO.Unsafe import System.IO.Unsafe
@ -31,12 +30,13 @@ import qualified Network.HTTP.Client as Client
data Req = Req data Req = Req
{ reqPath :: String { reqPath :: String
, qs :: QueryText , qs :: QueryText
, reqBody :: ByteString , reqBody :: Maybe (ByteString, MediaType)
, reqAccept :: [MediaType]
, headers :: [(String, Text)] , headers :: [(String, Text)]
} }
defReq :: Req defReq :: Req
defReq = Req "" [] "" [] defReq = Req "" [] Nothing [] []
appendToPath :: String -> Req -> Req appendToPath :: String -> Req -> Req
appendToPath p req = appendToPath p req =
@ -62,12 +62,12 @@ addHeader name val req = req { headers = headers req
++ [(name, toText val)] ++ [(name, toText val)]
} }
setRQBody :: ByteString -> Req -> Req setRQBody :: ByteString -> MediaType -> Req -> Req
setRQBody b req = req { reqBody = b } setRQBody b t req = req { reqBody = Just (b, t) }
reqToRequest :: (Functor m, MonadThrow m) => Req -> BaseUrl -> m Request reqToRequest :: (Functor m, MonadThrow m) => Req -> BaseUrl -> m Request
reqToRequest req (BaseUrl reqScheme reqHost reqPort) = 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 where url = show $ nullURI { uriScheme = case reqScheme of
Http -> "http:" Http -> "http:"
@ -80,10 +80,17 @@ reqToRequest req (BaseUrl reqScheme reqHost reqPort) =
, uriPath = reqPath req , 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 = requestHeaders r
++ [(hContentType, cs . show $ t)] }
setQS = setQueryString $ queryTextToQuery (qs req) setQS = setQueryString $ queryTextToQuery (qs req)
setheaders r = r { requestHeaders = Prelude.map toProperHeader (headers req) } setheaders r = r { requestHeaders = requestHeaders r
<> fmap toProperHeader (headers req) }
setAccept r = r { requestHeaders = filter ((/= "Accept") . fst) (requestHeaders r)
<> [("Accept", renderHeader $ reqAccept req)
| not . null . reqAccept $ req] }
toProperHeader (name, val) = toProperHeader (name, val) =
(fromString name, encodeUtf8 val) (fromString name, encodeUtf8 val)
@ -104,7 +111,7 @@ displayHttpRequest :: Method -> String
displayHttpRequest httpmethod = "HTTP " ++ cs httpmethod ++ " request" 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 performRequest reqMethod req isWantedStatus reqHost = do
partialRequest <- liftIO $ reqToRequest req reqHost partialRequest <- liftIO $ reqToRequest req reqHost
@ -123,20 +130,28 @@ performRequest reqMethod req isWantedStatus reqHost = do
let status = Client.responseStatus response let status = Client.responseStatus response
unless (isWantedStatus (statusCode status)) $ unless (isWantedStatus (statusCode status)) $
left (displayHttpRequest reqMethod ++ " failed with status: " ++ showStatus 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 where
showStatus (Status code message) = showStatus (Status code message) =
show code ++ " - " ++ cs message show code ++ " - " ++ cs message
performRequestCT :: MimeUnrender ct result =>
performRequestJSON :: FromJSON result => Proxy ct -> Method -> Req -> Int -> BaseUrl -> EitherT String IO result
Method -> Req -> Int -> BaseUrl -> EitherT String IO result performRequestCT ct reqMethod req wantedStatus reqHost = do
performRequestJSON reqMethod req wantedStatus reqHost = do let acceptCT = contentType ct
(_status, respBody) <- performRequest reqMethod req (== wantedStatus) reqHost (_status, respBody, respCT) <-
performRequest reqMethod (req { reqAccept = [acceptCT] }) (== wantedStatus) reqHost
unless (matches respCT (acceptCT)) $
left $ "requested Content-Type " <> show acceptCT <> ", but got " <> show respCT
either either
(\ message -> left (displayHttpRequest reqMethod ++ " returned invalid json: " ++ message)) (left . ((displayHttpRequest reqMethod ++ " returned invalid response of type" ++ show respCT) ++))
return return
(decodeLenient respBody) (fromByteString ct respBody)
catchStatusCodeException :: IO a -> IO (Either Status a) catchStatusCodeException :: IO a -> IO (Either Status a)
@ -145,10 +160,3 @@ catchStatusCodeException action =
case e of case e of
Client.StatusCodeException status _ _ -> return $ Left status Client.StatusCodeException status _ _ -> return $ Left status
exc -> throwIO exc 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

View file

@ -3,6 +3,7 @@
{-# LANGUAGE ViewPatterns #-} {-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -fcontext-stack=25 #-} {-# OPTIONS_GHC -fcontext-stack=25 #-}
module Servant.ClientSpec where module Servant.ClientSpec where
@ -14,9 +15,11 @@ import Data.Aeson
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import Data.Char import Data.Char
import Data.Foldable (forM_) import Data.Foldable (forM_)
import Data.Monoid
import Data.Proxy import Data.Proxy
import Data.Typeable import qualified Data.Text as T
import GHC.Generics import GHC.Generics
import Network.HTTP.Media
import Network.HTTP.Types import Network.HTTP.Types
import Network.Socket import Network.Socket
import Network.Wai import Network.Wai
@ -26,6 +29,7 @@ import Test.Hspec.QuickCheck
import Test.QuickCheck import Test.QuickCheck
import Servant.API import Servant.API
import Servant.API.ContentTypes
import Servant.Client import Servant.Client
import Servant.Server import Servant.Server
@ -40,28 +44,43 @@ data Person = Person {
instance ToJSON Person instance ToJSON Person
instance FromJSON 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 = Person "Alice" 42 alice = Person "Alice" 42
type Api = type Api =
"get" :> Get Person "get" :> Get '[JSON] Person
:<|> "delete" :> Delete :<|> "delete" :> Delete
:<|> "capture" :> Capture "name" String :> Get Person :<|> "capture" :> Capture "name" String :> Get '[JSON,FormUrlEncoded] Person
:<|> "body" :> ReqBody Person :> Post Person :<|> "body" :> ReqBody '[FormUrlEncoded,JSON] Person :> Post '[JSON] Person
:<|> "param" :> QueryParam "name" String :> Get Person :<|> "param" :> QueryParam "name" String :> Get '[FormUrlEncoded,JSON] Person
:<|> "params" :> QueryParams "names" String :> Get [Person] :<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person]
:<|> "flag" :> QueryFlag "flag" :> Get Bool :<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool
:<|> "matrixparam" :> MatrixParam "name" String :> Get Person :<|> "matrixparam" :> MatrixParam "name" String :> Get '[JSON] Person
:<|> "matrixparams" :> MatrixParams "name" String :> Get [Person] :<|> "matrixparams" :> MatrixParams "name" String :> Get '[JSON] [Person]
:<|> "matrixflag" :> MatrixFlag "flag" :> Get Bool :<|> "matrixflag" :> MatrixFlag "flag" :> Get '[JSON] Bool
:<|> "rawSuccess" :> Raw :<|> "rawSuccess" :> Raw
:<|> "rawFailure" :> Raw :<|> "rawFailure" :> Raw
:<|> "multiple" :> :<|> "multiple" :>
Capture "first" String :> Capture "first" String :>
QueryParam "second" Int :> QueryParam "second" Int :>
QueryFlag "third" :> QueryFlag "third" :>
ReqBody [(String, [Rational])] :> ReqBody '[JSON] [(String, [Rational])] :>
Get (String, Maybe Int, Bool, [(String, [Rational])]) Get '[JSON] (String, Maybe Int, Bool, [(String, [Rational])])
api :: Proxy Api api :: Proxy Api
api = Proxy api = Proxy
@ -101,8 +120,8 @@ getQueryFlag :: Bool -> BaseUrl -> EitherT String IO Bool
getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person getMatrixParam :: Maybe String -> BaseUrl -> EitherT String IO Person
getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person] getMatrixParams :: [String] -> BaseUrl -> EitherT String IO [Person]
getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool getMatrixFlag :: Bool -> BaseUrl -> EitherT String IO Bool
getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) getRawSuccess :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType)
getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString) getRawFailure :: Method -> BaseUrl -> EitherT String IO (Int, ByteString, MediaType)
getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])] getMultiple :: String -> Maybe Int -> Bool -> [(String, [Rational])]
-> BaseUrl -> BaseUrl
-> EitherT String IO (String, Maybe Int, Bool, [(String, [Rational])]) -> EitherT String IO (String, Maybe Int, Bool, [(String, [Rational])])
@ -151,6 +170,7 @@ spec = do
it (show flag) $ withServer $ \ host -> do it (show flag) $ withServer $ \ host -> do
runEitherT (getQueryFlag flag host) `shouldReturn` Right flag runEitherT (getQueryFlag flag host) `shouldReturn` Right flag
{-
it "Servant.API.MatrixParam" $ withServer $ \ host -> do it "Servant.API.MatrixParam" $ withServer $ \ host -> do
runEitherT (getMatrixParam (Just "alice") host) `shouldReturn` Right alice runEitherT (getMatrixParam (Just "alice") host) `shouldReturn` Right alice
Left result <- runEitherT (getMatrixParam (Just "bob") host) Left result <- runEitherT (getMatrixParam (Just "bob") host)
@ -165,12 +185,13 @@ spec = do
forM_ [False, True] $ \ flag -> forM_ [False, True] $ \ flag ->
it (show flag) $ withServer $ \ host -> do it (show flag) $ withServer $ \ host -> do
runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag runEitherT (getMatrixFlag flag host) `shouldReturn` Right flag
-}
it "Servant.API.Raw on success" $ withServer $ \ host -> do 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 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 modifyMaxSuccess (const 20) $ do
it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $ it "works for a combination of Capture, QueryParam, QueryFlag and ReqBody" $
@ -183,9 +204,9 @@ spec = do
context "client correctly handles error status codes" $ do context "client correctly handles error status codes" $ do
let test :: WrappedApi -> Spec let test :: (WrappedApi, String) -> Spec
test (WrappedApi api) = test (WrappedApi api, desc) =
it (show (typeOf api)) $ it desc $
withWaiDaemon (return (serve api (left (500, "error message")))) $ withWaiDaemon (return (serve api (left (500, "error message")))) $
\ host -> do \ host -> do
let getResponse :: BaseUrl -> EitherT String IO () let getResponse :: BaseUrl -> EitherT String IO ()
@ -193,16 +214,15 @@ spec = do
Left result <- runEitherT (getResponse host) Left result <- runEitherT (getResponse host)
result `shouldContain` "error message" result `shouldContain` "error message"
mapM_ test $ mapM_ test $
(WrappedApi (Proxy :: Proxy Delete)) : (WrappedApi (Proxy :: Proxy Delete), "Delete") :
(WrappedApi (Proxy :: Proxy (Get ()))) : (WrappedApi (Proxy :: Proxy (Get '[JSON] ())), "Delete") :
(WrappedApi (Proxy :: Proxy (Post ()))) : (WrappedApi (Proxy :: Proxy (Post '[JSON] ())), "Delete") :
(WrappedApi (Proxy :: Proxy (Put ()))) : (WrappedApi (Proxy :: Proxy (Put '[JSON] ())), "Delete") :
[] []
data WrappedApi where data WrappedApi where
WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a, WrappedApi :: (HasServer api, Server api ~ EitherT (Int, String) IO a,
HasClient api, Client api ~ (BaseUrl -> EitherT String IO ()), HasClient api, Client api ~ (BaseUrl -> EitherT String IO ())) =>
Typeable api) =>
Proxy api -> WrappedApi Proxy api -> WrappedApi