Skip to content

Commit c50fdef

Browse files
authored
Merge pull request #640 from fierce-katie/generic-client
Generic client
2 parents c09c0cf + af1b267 commit c50fdef

File tree

4 files changed

+235
-0
lines changed

4 files changed

+235
-0
lines changed

servant-client/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* client asks for any content-type in Accept contentTypes non-empty list
88
([#615](https://github.com/haskell-servant/servant/pull/615))
99

10+
* Add `ClientLike` class that matches client functions generated using `client` with client data structure.
11+
1012
0.9.1.1
1113
-------
1214

servant-client/servant-client.cabal

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ source-repository head
3030
library
3131
exposed-modules:
3232
Servant.Client
33+
Servant.Client.Generic
3334
Servant.Client.Experimental.Auth
3435
Servant.Common.BaseUrl
3536
Servant.Common.BasicAuth
@@ -42,6 +43,7 @@ library
4243
, base64-bytestring >= 1.0.0.1 && < 1.1
4344
, bytestring >= 0.10 && < 0.11
4445
, exceptions >= 0.8 && < 0.9
46+
, generics-sop >= 0.1.0.0 && < 0.3
4547
, http-api-data >= 0.3 && < 0.4
4648
, http-client >= 0.4.18.1 && < 0.6
4749
, http-client-tls >= 0.2.2 && < 0.4
@@ -96,3 +98,4 @@ test-suite spec
9698
, transformers-compat
9799
, wai
98100
, warp
101+
, generics-sop
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
{-# LANGUAGE CPP #-}
2+
{-# LANGUAGE DataKinds #-}
3+
{-# LANGUAGE DefaultSignatures #-}
4+
{-# LANGUAGE FlexibleContexts #-}
5+
{-# LANGUAGE FlexibleInstances #-}
6+
{-# LANGUAGE MultiParamTypeClasses #-}
7+
{-# LANGUAGE TypeFamilies #-}
8+
{-# LANGUAGE TypeOperators #-}
9+
{-# LANGUAGE UndecidableInstances #-}
10+
11+
#include "overlapping-compat.h"
12+
13+
module Servant.Client.Generic
14+
( ClientLike(..)
15+
, genericMkClientL
16+
, genericMkClientP
17+
) where
18+
19+
import Generics.SOP (Code, Generic, I(..), NP(..), NS(Z), SOP(..), to)
20+
import Servant.API ((:<|>)(..))
21+
import Servant.Client (ClientM)
22+
23+
-- | This class allows us to match client structure with client functions
24+
-- produced with 'client' without explicit pattern-matching.
25+
--
26+
-- The client structure needs a 'Generics.SOP.Generic' instance.
27+
--
28+
-- Example:
29+
--
30+
-- > type API
31+
-- > = "foo" :> Capture "x" Int :> Get '[JSON] Int
32+
-- > :<|> "bar" :> QueryParam "a" Char :> QueryParam "b" String :> Post '[JSON] [Int]
33+
-- > :<|> Capture "nested" Int :> NestedAPI
34+
-- >
35+
-- > type NestedAPI
36+
-- > = Get '[JSON] String
37+
-- > :<|> "baz" :> QueryParam "c" Char :> Post '[JSON] ()
38+
-- >
39+
-- > data APIClient = APIClient
40+
-- > { getFoo :: Int -> ClientM Int
41+
-- > , postBar :: Maybe Char -> Maybe String -> ClientM [Int]
42+
-- > , mkNestedClient :: Int -> NestedClient
43+
-- > } deriving GHC.Generic
44+
-- >
45+
-- > instance Generics.SOP.Generic APIClient
46+
-- > instance (Client API ~ client) => ClientLike client APIClient
47+
-- >
48+
-- > data NestedClient = NestedClient
49+
-- > { getString :: ClientM String
50+
-- > , postBaz :: Maybe Char -> ClientM ()
51+
-- > } deriving GHC.Generic
52+
-- >
53+
-- > instance Generics.SOP.Generic NestedClient
54+
-- > instance (Client NestedAPI ~ client) => ClientLike client NestedClient
55+
-- >
56+
-- > mkAPIClient :: APIClient
57+
-- > mkAPIClient = mkClient (client (Proxy :: Proxy API))
58+
--
59+
-- By default, left-nested alternatives are expanded:
60+
--
61+
-- > type API1
62+
-- > = "foo" :> Capture "x" Int :> Get '[JSON] Int
63+
-- > :<|> "bar" :> QueryParam "a" Char :> Post '[JSON] String
64+
-- >
65+
-- > type API2
66+
-- > = "baz" :> QueryParam "c" Char :> Post '[JSON] ()
67+
-- >
68+
-- > type API = API1 :<|> API2
69+
-- >
70+
-- > data APIClient = APIClient
71+
-- > { getFoo :: Int -> ClientM Int
72+
-- > , postBar :: Maybe Char -> ClientM String
73+
-- > , postBaz :: Maybe Char -> ClientM ()
74+
-- > } deriving GHC.Generic
75+
-- >
76+
-- > instance Generics.SOP.Generic APIClient
77+
-- > instance (Client API ~ client) => ClientLike client APIClient
78+
-- >
79+
-- > mkAPIClient :: APIClient
80+
-- > mkAPIClient = mkClient (client (Proxy :: Proxy API))
81+
--
82+
-- If you want to define client for @API1@ as a separate data structure,
83+
-- you can use 'genericMkClientP':
84+
--
85+
-- > data APIClient1 = APIClient1
86+
-- > { getFoo :: Int -> ClientM Int
87+
-- > , postBar :: Maybe Char -> ClientM String
88+
-- > } deriving GHC.Generic
89+
-- >
90+
-- > instance Generics.SOP.Generic APIClient1
91+
-- > instance (Client API1 ~ client) => ClientLike client APIClient1
92+
-- >
93+
-- > data APIClient = APIClient
94+
-- > { mkAPIClient1 :: APIClient1
95+
-- > , postBaz :: Maybe Char -> ClientM ()
96+
-- > } deriving GHC.Generic
97+
-- >
98+
-- > instance Generics.SOP.Generic APIClient
99+
-- > instance (Client API ~ client) => ClientLike client APIClient where
100+
-- > mkClient = genericMkClientP
101+
-- >
102+
-- > mkAPIClient :: APIClient
103+
-- > mkAPIClient = mkClient (client (Proxy :: Proxy API))
104+
class ClientLike client custom where
105+
mkClient :: client -> custom
106+
default mkClient :: (Generic custom, Code custom ~ '[xs], GClientList client '[], GClientLikeL (ClientList client '[]) xs)
107+
=> client -> custom
108+
mkClient = genericMkClientL
109+
110+
instance ClientLike client custom
111+
=> ClientLike (a -> client) (a -> custom) where
112+
mkClient c = mkClient . c
113+
114+
instance ClientLike (ClientM a) (ClientM a) where
115+
mkClient = id
116+
117+
-- | Match client structure with client functions, regarding left-nested API clients
118+
-- as separate data structures.
119+
class GClientLikeP client xs where
120+
gMkClientP :: client -> NP I xs
121+
122+
instance (GClientLikeP b (y ': xs), ClientLike a x)
123+
=> GClientLikeP (a :<|> b) (x ': y ': xs) where
124+
gMkClientP (a :<|> b) = I (mkClient a) :* gMkClientP b
125+
126+
instance ClientLike a x => GClientLikeP a '[x] where
127+
gMkClientP a = I (mkClient a) :* Nil
128+
129+
-- | Match client structure with client functions, expanding left-nested API clients
130+
-- in the same structure.
131+
class GClientLikeL (xs :: [*]) (ys :: [*]) where
132+
gMkClientL :: NP I xs -> NP I ys
133+
134+
instance GClientLikeL '[] '[] where
135+
gMkClientL Nil = Nil
136+
137+
instance (ClientLike x y, GClientLikeL xs ys) => GClientLikeL (x ': xs) (y ': ys) where
138+
gMkClientL (I x :* xs) = I (mkClient x) :* gMkClientL xs
139+
140+
type family ClientList (client :: *) (acc :: [*]) :: [*] where
141+
ClientList (a :<|> b) acc = ClientList a (ClientList b acc)
142+
ClientList a acc = a ': acc
143+
144+
class GClientList client (acc :: [*]) where
145+
gClientList :: client -> NP I acc -> NP I (ClientList client acc)
146+
147+
instance (GClientList b acc, GClientList a (ClientList b acc))
148+
=> GClientList (a :<|> b) acc where
149+
gClientList (a :<|> b) acc = gClientList a (gClientList b acc)
150+
151+
instance OVERLAPPABLE_ (ClientList client acc ~ (client ': acc))
152+
=> GClientList client acc where
153+
gClientList c acc = I c :* acc
154+
155+
-- | Generate client structure from client type, expanding left-nested API (done by default).
156+
genericMkClientL :: (Generic custom, Code custom ~ '[xs], GClientList client '[], GClientLikeL (ClientList client '[]) xs)
157+
=> client -> custom
158+
genericMkClientL = to . SOP . Z . gMkClientL . flip gClientList Nil
159+
160+
-- | Generate client structure from client type, regarding left-nested API clients as separate data structures.
161+
genericMkClientP :: (Generic custom, Code custom ~ '[xs], GClientLikeP client xs)
162+
=> client -> custom
163+
genericMkClientP = to . SOP . Z . gMkClientP
164+

servant-client/test/Servant/ClientSpec.hs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import Data.Char (chr, isPrint)
3636
import Data.Foldable (forM_)
3737
import Data.Monoid hiding (getLast)
3838
import Data.Proxy
39+
import qualified Generics.SOP as SOP
3940
import GHC.Generics (Generic)
4041
import qualified Network.HTTP.Client as C
4142
import Network.HTTP.Media
@@ -55,6 +56,7 @@ import Web.FormUrlEncoded (FromForm, ToForm)
5556
import Servant.API
5657
import Servant.API.Internal.Test.ComprehensiveAPI
5758
import Servant.Client
59+
import Servant.Client.Generic
5860
import qualified Servant.Common.Req as SCR
5961
import Servant.Server
6062
import Servant.Server.Experimental.Auth
@@ -69,6 +71,7 @@ spec = describe "Servant.Client" $ do
6971
wrappedApiSpec
7072
basicAuthSpec
7173
genAuthSpec
74+
genericClientSpec
7275

7376
-- * test data types
7477

@@ -222,6 +225,53 @@ genAuthServerContext = genAuthHandler :. EmptyContext
222225
genAuthServer :: Application
223226
genAuthServer = serveWithContext genAuthAPI genAuthServerContext (const (return alice))
224227

228+
-- * generic client stuff
229+
230+
type GenericClientAPI
231+
= QueryParam "sqr" Int :> Get '[JSON] Int
232+
:<|> Capture "foo" String :> NestedAPI1
233+
234+
data GenericClient = GenericClient
235+
{ getSqr :: Maybe Int -> SCR.ClientM Int
236+
, mkNestedClient1 :: String -> NestedClient1
237+
} deriving Generic
238+
instance SOP.Generic GenericClient
239+
instance (Client GenericClientAPI ~ client) => ClientLike client GenericClient
240+
241+
type NestedAPI1
242+
= QueryParam "int" Int :> NestedAPI2
243+
:<|> QueryParam "id" Char :> Get '[JSON] Char
244+
245+
data NestedClient1 = NestedClient1
246+
{ mkNestedClient2 :: Maybe Int -> NestedClient2
247+
, idChar :: Maybe Char -> SCR.ClientM Char
248+
} deriving Generic
249+
instance SOP.Generic NestedClient1
250+
instance (Client NestedAPI1 ~ client) => ClientLike client NestedClient1
251+
252+
type NestedAPI2
253+
= "sum" :> Capture "first" Int :> Capture "second" Int :> Get '[JSON] Int
254+
:<|> "void" :> Post '[JSON] ()
255+
256+
data NestedClient2 = NestedClient2
257+
{ getSum :: Int -> Int -> SCR.ClientM Int
258+
, doNothing :: SCR.ClientM ()
259+
} deriving Generic
260+
instance SOP.Generic NestedClient2
261+
instance (Client NestedAPI2 ~ client) => ClientLike client NestedClient2
262+
263+
genericClientServer :: Application
264+
genericClientServer = serve (Proxy :: Proxy GenericClientAPI) (
265+
(\ mx -> case mx of
266+
Just x -> return (x*x)
267+
Nothing -> throwError $ ServantErr 400 "missing parameter" "" []
268+
)
269+
:<|> nestedServer1
270+
)
271+
where
272+
nestedServer1 _str = nestedServer2 :<|> (maybe (throwError $ ServantErr 400 "missing parameter" "" []) return)
273+
nestedServer2 _int = (\ x y -> return (x + y)) :<|> return ()
274+
225275
{-# NOINLINE manager #-}
226276
manager :: C.Manager
227277
manager = unsafePerformIO $ C.newManager C.defaultManagerSettings
@@ -392,6 +442,22 @@ genAuthSpec = beforeAll (startWaiApp genAuthServer) $ afterAll endWaiApp $ do
392442
Left FailureResponse{..} <- runClientM (getProtected authRequest) (ClientEnv manager baseUrl)
393443
responseStatus `shouldBe` (HTTP.Status 401 "Unauthorized")
394444

445+
genericClientSpec :: Spec
446+
genericClientSpec = beforeAll (startWaiApp genericClientServer) $ afterAll endWaiApp $ do
447+
describe "Servant.Client.Generic" $ do
448+
449+
let GenericClient{..} = mkClient (client (Proxy :: Proxy GenericClientAPI))
450+
NestedClient1{..} = mkNestedClient1 "example"
451+
NestedClient2{..} = mkNestedClient2 (Just 42)
452+
453+
it "works for top-level client function" $ \(_, baseUrl) -> do
454+
(left show <$> (runClientM (getSqr (Just 5)) (ClientEnv manager baseUrl))) `shouldReturn` Right 25
455+
456+
it "works for nested clients" $ \(_, baseUrl) -> do
457+
(left show <$> (runClientM (idChar (Just 'c')) (ClientEnv manager baseUrl))) `shouldReturn` Right 'c'
458+
(left show <$> (runClientM (getSum 3 4) (ClientEnv manager baseUrl))) `shouldReturn` Right 7
459+
(left show <$> (runClientM doNothing (ClientEnv manager baseUrl))) `shouldReturn` Right ()
460+
395461
-- * utils
396462

397463
startWaiApp :: Application -> IO (ThreadId, BaseUrl)

0 commit comments

Comments
 (0)