Skip to content

Commit 1bb0282

Browse files
author
Gaël Deest
authored
Merge pull request #1388 from gdeest/generic-apis
Improve API for composing generic routes
2 parents 04e4de5 + 575aa70 commit 1bb0282

File tree

15 files changed

+359
-60
lines changed

15 files changed

+359
-60
lines changed

servant-client-core/servant-client-core.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ library
5252
build-depends:
5353
base >= 4.9 && < 4.16
5454
, bytestring >= 0.10.8.1 && < 0.12
55+
, constraints >= 0.2 && < 0.14
5556
, containers >= 0.5.7.1 && < 0.7
5657
, deepseq >= 1.4.2.0 && < 1.5
5758
, text >= 1.2.3.0 && < 1.3

servant-client-core/src/Servant/Client/Core/HasClient.hs

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
11
{-# LANGUAGE ConstraintKinds #-}
2-
{-# LANGUAGE CPP #-}
32
{-# LANGUAGE DataKinds #-}
43
{-# LANGUAGE FlexibleContexts #-}
54
{-# LANGUAGE FlexibleInstances #-}
65
{-# LANGUAGE InstanceSigs #-}
76
{-# LANGUAGE MultiParamTypeClasses #-}
87
{-# LANGUAGE OverloadedStrings #-}
98
{-# LANGUAGE PolyKinds #-}
9+
{-# LANGUAGE QuantifiedConstraints #-}
1010
{-# LANGUAGE RankNTypes #-}
1111
{-# LANGUAGE ScopedTypeVariables #-}
1212
{-# LANGUAGE TypeApplications #-}
1313
{-# LANGUAGE TypeFamilies #-}
1414
{-# LANGUAGE TypeOperators #-}
1515
{-# LANGUAGE UndecidableInstances #-}
1616

17-
#if MIN_VERSION_base(4,9,0) && __GLASGOW_HASKELL__ >= 802
18-
#define HAS_TYPE_ERROR
19-
#endif
20-
2117
module Servant.Client.Core.HasClient (
2218
clientIn,
2319
HasClient (..),
2420
EmptyClient (..),
21+
AsClientT,
22+
(//),
23+
(/:),
2524
foldMapUnion,
2625
matchUnion,
2726
) where
@@ -39,6 +38,7 @@ import Data.ByteString.Builder
3938
import qualified Data.ByteString.Lazy as BL
4039
import Data.Either
4140
(partitionEithers)
41+
import Data.Constraint (Dict(..))
4242
import Data.Foldable
4343
(toList)
4444
import Data.List
@@ -47,7 +47,8 @@ import Data.Sequence
4747
(fromList)
4848
import qualified Data.Text as T
4949
import Network.HTTP.Media
50-
(MediaType, matches, parseAccept, (//))
50+
(MediaType, matches, parseAccept)
51+
import qualified Network.HTTP.Media as Media
5152
import qualified Data.Sequence as Seq
5253
import Data.SOP.BasicFunctors
5354
(I (I), (:.:) (Comp))
@@ -79,7 +80,10 @@ import Servant.API
7980
ReflectMethod (..), RemoteHost, ReqBody', SBoolI, Stream,
8081
StreamBody', Summary, ToHttpApiData, ToSourceIO (..), Vault,
8182
Verb, WithNamedContext, WithStatus (..), contentType, getHeadersHList,
82-
getResponse, toEncodedUrlPiece, toUrlPiece)
83+
getResponse, toEncodedUrlPiece, toUrlPiece, NamedRoutes)
84+
import Servant.API.Generic
85+
(GenericMode(..), ToServant, ToServantApi
86+
, GenericServant, toServant, fromServant)
8387
import Servant.API.ContentTypes
8488
(contentTypes, AllMime (allMime), AllMimeUnrender (allMimeUnrender))
8589
import Servant.API.TypeLevel (FragmentUnique, AtLeastOneFragment)
@@ -792,11 +796,7 @@ instance ( HasClient m api
792796
-- > getBooks = client myApi
793797
-- > -- then you can just use "getBooksBy" to query that endpoint.
794798
-- > -- 'getBooks' for all books.
795-
#ifdef HAS_TYPE_ERROR
796799
instance (AtLeastOneFragment api, FragmentUnique (Fragment a :> api), HasClient m api
797-
#else
798-
instance ( HasClient m api
799-
#endif
800800
) => HasClient m (Fragment a :> api) where
801801

802802
type Client m (Fragment a :> api) = Client m api
@@ -816,6 +816,119 @@ instance HasClient m api => HasClient m (BasicAuth realm usr :> api) where
816816
hoistClientMonad pm _ f cl = \bauth ->
817817
hoistClientMonad pm (Proxy :: Proxy api) f (cl bauth)
818818

819+
-- | A type that specifies that an API record contains a client implementation.
820+
data AsClientT (m :: * -> *)
821+
instance GenericMode (AsClientT m) where
822+
type AsClientT m :- api = Client m api
823+
824+
825+
type GClientConstraints api m =
826+
( GenericServant api (AsClientT m)
827+
, Client m (ToServantApi api) ~ ToServant api (AsClientT m)
828+
)
829+
830+
class GClient (api :: * -> *) m where
831+
proof :: Dict (GClientConstraints api m)
832+
833+
instance GClientConstraints api m => GClient api m where
834+
proof = Dict
835+
836+
instance
837+
( forall n. GClient api n
838+
, HasClient m (ToServantApi api)
839+
, RunClient m
840+
)
841+
=> HasClient m (NamedRoutes api) where
842+
type Client m (NamedRoutes api) = api (AsClientT m)
843+
844+
clientWithRoute :: Proxy m -> Proxy (NamedRoutes api) -> Request -> Client m (NamedRoutes api)
845+
clientWithRoute pm _ request =
846+
case proof @api @m of
847+
Dict -> fromServant $ clientWithRoute pm (Proxy @(ToServantApi api)) request
848+
849+
hoistClientMonad
850+
:: forall ma mb.
851+
Proxy m
852+
-> Proxy (NamedRoutes api)
853+
-> (forall x. ma x -> mb x)
854+
-> Client ma (NamedRoutes api)
855+
-> Client mb (NamedRoutes api)
856+
hoistClientMonad _ _ nat clientA =
857+
case (proof @api @ma, proof @api @mb) of
858+
(Dict, Dict) ->
859+
fromServant @api @(AsClientT mb) $
860+
hoistClientMonad @m @(ToServantApi api) @ma @mb Proxy Proxy nat $
861+
toServant @api @(AsClientT ma) clientA
862+
863+
infixl 1 //
864+
infixl 2 /:
865+
866+
-- | Helper to make code using records of clients more readable.
867+
--
868+
-- Can be mixed with (/:) for supplying arguments.
869+
--
870+
-- Example:
871+
--
872+
-- @@
873+
-- type Api = NamedRoutes RootApi
874+
--
875+
-- data RootApi mode = RootApi
876+
-- { subApi :: mode :- NamedRoutes SubApi
877+
-- , …
878+
-- } deriving Generic
879+
--
880+
-- data SubApi mode = SubApi
881+
-- { endpoint :: mode :- Get '[JSON] Person
882+
-- , …
883+
-- } deriving Generic
884+
--
885+
-- api :: Proxy API
886+
-- api = Proxy
887+
--
888+
-- rootClient :: RootApi (AsClientT ClientM)
889+
-- rootClient = client api
890+
--
891+
-- endpointClient :: ClientM Person
892+
-- endpointClient = client // subApi // endpoint
893+
-- @@
894+
(//) :: a -> (a -> b) -> b
895+
x // f = f x
896+
897+
-- | Convenience function for supplying arguments to client functions when
898+
-- working with records of clients.
899+
--
900+
-- Intended to be used in conjunction with '(//)'.
901+
--
902+
-- Example:
903+
--
904+
-- @@
905+
-- type Api = NamedRoutes RootApi
906+
--
907+
-- data RootApi mode = RootApi
908+
-- { subApi :: mode :- Capture "token" String :> NamedRoutes SubApi
909+
-- , hello :: mode :- Capture "name" String :> Get '[JSON] String
910+
-- , …
911+
-- } deriving Generic
912+
--
913+
-- data SubApi mode = SubApi
914+
-- { endpoint :: mode :- Get '[JSON] Person
915+
-- , …
916+
-- } deriving Generic
917+
--
918+
-- api :: Proxy API
919+
-- api = Proxy
920+
--
921+
-- rootClient :: RootApi (AsClientT ClientM)
922+
-- rootClient = client api
923+
--
924+
-- hello :: String -> ClientM String
925+
-- hello name = rootClient // hello /: name
926+
--
927+
-- endpointClient :: ClientM Person
928+
-- endpointClient = client // subApi /: "foobar123" // endpoint
929+
-- @@
930+
(/:) :: (a -> b -> c) -> b -> a -> c
931+
(/:) = flip
819932

820933

821934
{- Note [Non-Empty Content Types]
@@ -841,7 +954,7 @@ for empty and one for non-empty lists).
841954
checkContentTypeHeader :: RunClient m => Response -> m MediaType
842955
checkContentTypeHeader response =
843956
case lookup "Content-Type" $ toList $ responseHeaders response of
844-
Nothing -> return $ "application"//"octet-stream"
957+
Nothing -> return $ "application" Media.// "octet-stream"
845958
Just t -> case parseAccept t of
846959
Nothing -> throwClientError $ InvalidContentTypeHeader response
847960
Just t' -> return t'

servant-client-core/src/Servant/Client/Core/Reexport.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ module Servant.Client.Core.Reexport
77
HasClient(..)
88
, foldMapUnion
99
, matchUnion
10+
, AsClientT
11+
, (//)
12+
, (/:)
1013

1114
-- * Response (for @Raw@)
1215
, Response
@@ -23,6 +26,7 @@ module Servant.Client.Core.Reexport
2326
, showBaseUrl
2427
, parseBaseUrl
2528
, InvalidBaseUrlException
29+
2630
) where
2731

2832

servant-client-core/src/Servant/Client/Generic.hs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
{-# LANGUAGE ConstraintKinds #-}
2-
{-# LANGUAGE FlexibleContexts #-}
3-
{-# LANGUAGE KindSignatures #-}
4-
{-# LANGUAGE RankNTypes #-}
5-
{-# LANGUAGE ScopedTypeVariables #-}
6-
{-# LANGUAGE TypeFamilies #-}
1+
{-# OPTIONS_GHC -fno-warn-orphans #-}
2+
{-# LANGUAGE FlexibleContexts #-}
3+
{-# LANGUAGE KindSignatures #-}
4+
{-# LANGUAGE RankNTypes #-}
5+
{-# LANGUAGE ScopedTypeVariables #-}
6+
{-# LANGUAGE TypeFamilies #-}
7+
78
module Servant.Client.Generic (
89
AsClientT,
910
genericClient,
@@ -15,11 +16,7 @@ import Data.Proxy
1516

1617
import Servant.API.Generic
1718
import Servant.Client.Core
18-
19-
-- | A type that specifies that an API record contains a client implementation.
20-
data AsClientT (m :: * -> *)
21-
instance GenericMode (AsClientT m) where
22-
type AsClientT m :- api = Client m api
19+
import Servant.Client.Core.HasClient (AsClientT)
2320

2421
-- | Generate a record of client functions.
2522
genericClient

servant-client/servant-client.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ test-suite spec
9393
Servant.ConnectionErrorSpec
9494
Servant.FailSpec
9595
Servant.GenAuthSpec
96+
Servant.GenericSpec
9697
Servant.HoistClientSpec
9798
Servant.StreamSpec
9899
Servant.SuccessSpec

servant-client/test/Servant/ClientTestUtils.hs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ import Servant.API
6464
JSON, MimeRender (mimeRender), MimeUnrender (mimeUnrender),
6565
NoContent (NoContent), PlainText, Post, QueryFlag, QueryParam,
6666
QueryParams, Raw, ReqBody, StdMethod (GET), ToHttpApiData (..), UVerb, Union,
67-
WithStatus (WithStatus), addHeader)
67+
WithStatus (WithStatus), NamedRoutes, addHeader)
68+
import Servant.API.Generic ((:-))
6869
import Servant.Client
6970
import qualified Servant.Client.Core.Auth as Auth
7071
import Servant.Server
@@ -107,6 +108,16 @@ carol = Person "Carol" 17
107108

108109
type TestHeaders = '[Header "X-Example1" Int, Header "X-Example2" String]
109110

111+
data RecordRoutes mode = RecordRoutes
112+
{ version :: mode :- "version" :> Get '[JSON] Int
113+
, echo :: mode :- "echo" :> Capture "string" String :> Get '[JSON] String
114+
, otherRoutes :: mode :- "other" :> Capture "someParam" Int :> NamedRoutes OtherRoutes
115+
} deriving Generic
116+
117+
data OtherRoutes mode = OtherRoutes
118+
{ something :: mode :- "something" :> Get '[JSON] [String]
119+
} deriving Generic
120+
110121
type Api =
111122
Get '[JSON] Person
112123
:<|> "get" :> Get '[JSON] Person
@@ -141,6 +152,7 @@ type Api =
141152
UVerb 'GET '[PlainText] '[WithStatus 200 Person,
142153
WithStatus 301 Text]
143154
:<|> "uverb-get-created" :> UVerb 'GET '[PlainText] '[WithStatus 201 Person]
155+
:<|> NamedRoutes RecordRoutes
144156

145157

146158
api :: Proxy Api
@@ -170,6 +182,7 @@ uverbGetSuccessOrRedirect :: Bool
170182
-> ClientM (Union '[WithStatus 200 Person,
171183
WithStatus 301 Text])
172184
uverbGetCreated :: ClientM (Union '[WithStatus 201 Person])
185+
recordRoutes :: RecordRoutes (AsClientT ClientM)
173186

174187
getRoot
175188
:<|> getGet
@@ -192,7 +205,8 @@ getRoot
192205
:<|> getRedirectWithCookie
193206
:<|> EmptyClient
194207
:<|> uverbGetSuccessOrRedirect
195-
:<|> uverbGetCreated = client api
208+
:<|> uverbGetCreated
209+
:<|> recordRoutes = client api
196210

197211
server :: Application
198212
server = serve api (
@@ -229,6 +243,13 @@ server = serve api (
229243
then respond (WithStatus @301 ("redirecting" :: Text))
230244
else respond (WithStatus @200 alice ))
231245
:<|> respond (WithStatus @201 carol)
246+
:<|> RecordRoutes
247+
{ version = pure 42
248+
, echo = pure
249+
, otherRoutes = \_ -> OtherRoutes
250+
{ something = pure ["foo", "bar", "pweet"]
251+
}
252+
}
232253
)
233254

234255
type FailApi =
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{-# LANGUAGE CPP #-}
2+
{-# LANGUAGE ConstraintKinds #-}
3+
{-# LANGUAGE DataKinds #-}
4+
{-# LANGUAGE FlexibleContexts #-}
5+
{-# LANGUAGE FlexibleInstances #-}
6+
{-# LANGUAGE GADTs #-}
7+
{-# LANGUAGE MultiParamTypeClasses #-}
8+
{-# LANGUAGE OverloadedStrings #-}
9+
{-# LANGUAGE PolyKinds #-}
10+
{-# LANGUAGE ScopedTypeVariables #-}
11+
{-# LANGUAGE TypeFamilies #-}
12+
{-# LANGUAGE TypeOperators #-}
13+
{-# LANGUAGE UndecidableInstances #-}
14+
{-# OPTIONS_GHC -freduction-depth=100 #-}
15+
{-# OPTIONS_GHC -fno-warn-orphans #-}
16+
{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
17+
18+
module Servant.GenericSpec (spec) where
19+
20+
import Test.Hspec
21+
22+
import Servant.Client ((//), (/:))
23+
import Servant.ClientTestUtils
24+
25+
spec :: Spec
26+
spec = describe "Servant.GenericSpec" $ do
27+
genericSpec
28+
29+
genericSpec :: Spec
30+
genericSpec = beforeAll (startWaiApp server) $ afterAll endWaiApp $ do
31+
context "Record clients work as expected" $ do
32+
33+
it "Client functions return expected values" $ \(_,baseUrl) -> do
34+
runClient (recordRoutes // version) baseUrl `shouldReturn` Right 42
35+
runClient (recordRoutes // echo /: "foo") baseUrl `shouldReturn` Right "foo"
36+
it "Clients can be nested" $ \(_,baseUrl) -> do
37+
runClient (recordRoutes // otherRoutes /: 42 // something) baseUrl `shouldReturn` Right ["foo", "bar", "pweet"]

0 commit comments

Comments
 (0)