on master help sign in
Merge pull request #17 from mwotton/servant-client

Servant client

commit018ff13fb5ba9123f0ee3718373b0acfe7daddea authorMark Wotton <mwotton@gmail.com> dateTue 16 Mar 2021, 15:54 (6 years ago) parent8276fb61728c99
16 files changed+474−195
EXAMPLE.md+40−3 Makefile+1−1 benchmarks/genFile.sh+15−2 benchmarks/prelude+7−16 benchmarks/runTiming.sh+2−2 package.yaml+8−1 roboservant.cabal+13−3 src/Roboservant/Client.hs+119 src/Roboservant/Direct.hs+19−19 src/Roboservant/Server.hs+36 src/Roboservant/Types.hs+2−2 src/Roboservant/Types/BuildFrom.hs−1 src/Roboservant/Types/FlattenServer.hs−40 src/Roboservant/Types/ReifiedApi.hs+17−49 src/Roboservant/Types/ReifiedApi/Server.hs+113 test/Spec.hs+82−56
EXAMPLE.md+40−3
@@ -13,23 +13,37 @@ Our api under test:
1313{-# LANGUAGE DeriveGeneric #-}
1414{-# LANGUAGE GeneralizedNewtypeDeriving #-}
1515{-# LANGUAGE RecordWildCards #-}
16+{-# LANGUAGE ScopedTypeVariables #-}
17+{-# LANGUAGE TypeApplications #-}
1618
17−import Roboservant
19+import qualified Roboservant.Server as RS
20+import qualified Roboservant.Client as RC
21+import Servant.Client(ClientEnv, baseUrlPort, parseBaseUrl, mkClientEnv)
22+import Network.HTTP.Client (newManager, defaultManagerSettings)
23+import Roboservant.Types
1824import Test.Hspec
1925import Servant
2026import GHC.Generics
2127import Data.Typeable
2228import Data.Hashable
2329import Data.Maybe(isNothing, isJust)
30+import qualified Network.Wai.Handler.Warp as Warp
31+import Data.Aeson(FromJSON,ToJSON)
2432
2533newtype A = A Int
2634 deriving (Generic, Eq, Show, Typeable)
2735 deriving newtype (Hashable, FromHttpApiData, ToHttpApiData)
2836
37+instance FromJSON A
38+instance ToJSON A
39+
2940newtype B = B Int
3041 deriving (Generic, Eq, Show, Typeable)
3142 deriving newtype (Hashable, FromHttpApiData, ToHttpApiData)
3243
44+instance FromJSON B
45+instance ToJSON B
46+
3347type Api =
3448 "item" :> Get '[JSON] A
3549 :<|> "itemAdd" :> Capture "one" B :> Capture "two" B :> Get '[JSON] B
@@ -62,13 +76,24 @@ main = hspec spec
6276spec :: Spec
6377spec = describe "example" $ do
6478 it "good server should not fail" $ do
65− fuzz @Api goodServer config
79+ RS.fuzz @Api goodServer config
6680 >>= (`shouldSatisfy` isNothing)
6781 it "bad server should fail" $ do
68− fuzz @Api badServer config
82+ RS.fuzz @Api badServer config
6983 >>= (`shouldSatisfy` isJust)
7084```
7185
86+The previous test just picked apart the server and ran functions manually: sometimes, we want to test via
87+an honest-to-goodness network port, like so:
88+
89+```haskell
90+ around (withServer (serve (Proxy :: Proxy Api) badServer)) $ do
91+ it "we should also be able to run the _client_ to an independent server (ignore server error messages)" $ \(clientEnv::ClientEnv) -> do
92+ RC.fuzz @Api clientEnv config >>= (`shouldSatisfy` isJust)
93+```
94+
95+(we use withApplication rather than testWithApplication because we don't primarily care what the server does here:
96+we want to check what a client does when presented with a faulty server.)
7297
7398We expect to be able to cover the whole api from our starting point, so let's set the coverage to 0.99.
7499There are other tweakable things in the config, like maximum runtime, reps,
@@ -104,3 +129,15 @@ build it up from components.
104129deriving via (Compound B) instance BuildFrom B
105130deriving via (Atom B) instance Breakdown B
106131```
132+
133+
134+test utilities:
135+
136+``` haskell
137+withServer :: Application -> ActionWith ClientEnv -> IO ()
138+withServer app action = Warp.withApplication (pure app) (\p -> genClientEnv p >>= action)
139+ where genClientEnv port = do
140+ baseUrl <- parseBaseUrl "http://localhost"
141+ manager <- newManager defaultManagerSettings
142+ pure $ mkClientEnv manager (baseUrl { baseUrlPort = port })
143+```
Makefile+1−1
@@ -1,4 +1,4 @@
11testwatch:
2− ghcid -T main -c 'stack repl --test --ghc-options=-fobject-code' --allow-eval --restart="stack.yaml" --restart="package.yaml" -W
2+ ghcid -T main -c 'stack repl roboservant:lib roboservant:test:roboservant-test --test --ghc-options=-fobject-code' --allow-eval --restart="stack.yaml" --restart="package.yaml" -W
33pedanticwatch:
44 ghcid -c 'stack repl --test --ghc-options=-fobject-code' --allow-eval --restart="stack.yaml" --restart="package.yaml" -W
benchmarks/genFile.sh+15−2
@@ -1,9 +1,11 @@
11n=$1
2+k=$2
23
34cat prelude
4−echo -n "type Api = \"route1\" :> ReqBody '[JSON] Input :> Post '[JSON] Output"
5+echo -n "type SubApi = \"route1\" :> ReqBody '[JSON] Input :> Post '[JSON] Output"
56for i in $(seq 2 $n); do
6− echo " :<|> \"route${i}\" :> ReqBody '[JSON] Input :> Post '[JSON] Output"
7+ echo -n " :<|> \"route${i}\" :> "
8+ echo "ReqBody '[JSON] Input :> Post '[JSON] Output"
79done
810
911echo -n "server = handler "
@@ -11,3 +13,14 @@ echo -n "server = handler "
1113for i in $(seq 2 $n); do
1214 echo -n ":<|> handler "
1315done
16+echo
17+echo
18+
19+echo -n "type Api = Flatten ("
20+for i in $(seq 1 $k); do
21+ echo -n '"foo" :> '
22+done;
23+
24+echo "SubApi )"
25+
26+
benchmarks/prelude+7−16
@@ -12,6 +12,7 @@ import Servant
1212import Data.Hashable
1313import GHC.Generics
1414import Data.Aeson
15+import Servant.API.Flatten
1516
1617data Input = Input1 { foo :: String }
1718 | Input2 { bar :: Int }
@@ -52,19 +53,9 @@ handler = pure . \case
5253 Input1 foo -> Output1 foo
5354 Input2 bar -> Output2 bar
5455
55−main = fuzz @Api server defaultConfig (pure ())
56−
57−defaultConfig :: Config
58−defaultConfig = Config {
59− -- you can pass extra values in using the seed argument. This can be useful
60− -- for things that might not be produceable within the api, like auth tokens.
61− seed = [hashedDyn "blah"]
62− , maxRuntime = 0.5
63− -- if we get to 1000 interactions with the api, call it quits.
64− , maxReps = 1000
65− -- if you're using this inside quickcheck or hedgehog, you might want to set this
66− -- from their seed to make sure it stays deterministic
67− , rngSeed = 0
68− -- 0 to 100: fail tests if we hit less than this percentage of endpoints.
69− , coverageThreshold = 0
70− }
56+main =
57+ -- fuzz @Api server defaultConfig
58+ pure ()
59+
60+f = flatten (Proxy @Api)
61+
benchmarks/runTiming.sh+2−2
@@ -1,8 +1,8 @@
11lo=$1
22hi=$2
3−
3+k=$3
44for i in $(seq $lo 5 $hi); do
5− ./genFile.sh $i > main.hs;
5+ ./genFile.sh $i $k > main.hs;
66 echo "sievedn: $i,";
77 /usr/bin/time -f "%e" stack ghc main.hs 2>&1;
88# /usr/bin/time -f "%e" stack ghc -- -fomit-interface-pragmas main.hs 2>&1;
package.yaml+8−1
@@ -55,7 +55,10 @@ tests:
5555 - hspec
5656 - hspec-core
5757 - http-api-data
58−
58+ - http-client
59+ - hspec-wai
60+ - wai
61+ - warp
5962 example:
6063 main: Example.lhs
6164 source-dirs: .
@@ -63,6 +66,10 @@ tests:
6366 dependencies:
6467 - roboservant
6568 - hspec
69+ - warp
70+ - http-client
71+ - aeson
72+ - hspec-core
6673 ghc-options: -pgmL markdown-unlit
6774
6875 build-tools: markdown-unlit
roboservant.cabal+13−3
@@ -4,7 +4,7 @@ cabal-version: 1.12
44--
55-- see: https://github.com/sol/hpack
66--
7−-- hash: 48773018ec618e9eba02a126e3aeb93781a1d68ce28e2c405c35e9536bf94f70
7+-- hash: 4c71b6b9460f4719791bb8e67f4a5398b807cd8d17c43dc69da806bb5ba9fb4b
88
99name: roboservant
1010version: 0.1.0.2
@@ -28,14 +28,16 @@ source-repository head
2828library
2929 exposed-modules:
3030 Roboservant
31+ Roboservant.Client
3132 Roboservant.Direct
33+ Roboservant.Server
3234 Roboservant.Types
3335 Roboservant.Types.Breakdown
3436 Roboservant.Types.BuildFrom
3537 Roboservant.Types.Config
36− Roboservant.Types.FlattenServer
3738 Roboservant.Types.Internal
3839 Roboservant.Types.ReifiedApi
40+ Roboservant.Types.ReifiedApi.Server
3941 other-modules:
4042 Paths_roboservant
4143 hs-source-dirs:
@@ -74,13 +76,16 @@ test-suite example
7476 build-tool-depends:
7577 markdown-unlit:markdown-unlit
7678 build-depends:
77− base >=4.7 && <5
79+ aeson
80+ , base >=4.7 && <5
7881 , bytestring
7982 , containers
8083 , dependent-map
8184 , dependent-sum
8285 , hashable
8386 , hspec
87+ , hspec-core
88+ , http-client
8489 , lifted-base
8590 , monad-control
8691 , mtl
@@ -95,6 +100,7 @@ test-suite example
95100 , time
96101 , unordered-containers
97102 , vinyl
103+ , warp
98104 default-language: Haskell2010
99105
100106test-suite roboservant-test
@@ -124,7 +130,9 @@ test-suite roboservant-test
124130 , hashable
125131 , hspec
126132 , hspec-core
133+ , hspec-wai
127134 , http-api-data
135+ , http-client
128136 , lifted-base
129137 , monad-control
130138 , mtl
@@ -139,4 +147,6 @@ test-suite roboservant-test
139147 , time
140148 , unordered-containers
141149 , vinyl
150+ , wai
151+ , warp
142152 default-language: Haskell2010
src/Roboservant/Client.hsnew file+119
@@ -0,0 +1,119 @@
1+{-# LANGUAGE RankNTypes #-}
2+{-# LANGUAGE TypeOperators #-}
3+{-# LANGUAGE DataKinds #-}
4+{-# LANGUAGE AllowAmbiguousTypes #-}
5+{-# LANGUAGE DataKinds #-}
6+{-# LANGUAGE GADTs #-}
7+{-# LANGUAGE PolyKinds #-}
8+{-# LANGUAGE ScopedTypeVariables #-}
9+{-# LANGUAGE TypeApplications #-}
10+{-# LANGUAGE TypeOperators #-}
11+{-# LANGUAGE UndecidableInstances #-}
12+{-# LANGUAGE TypeFamilies #-}
13+{-# LANGUAGE FlexibleContexts #-}
14+-- should all the NormalizeFunction instances be in one place?
15+{-# OPTIONS_GHC -fno-warn-orphans #-}
16+module Roboservant.Client where
17+
18+import Data.Proxy
19+import Servant.Client
20+import Roboservant.Types
21+import Roboservant(Report, fuzz')
22+import Servant
23+import Data.Bifunctor
24+import Data.List.NonEmpty (NonEmpty)
25+import Data.Dynamic (Dynamic,Typeable)
26+import qualified Data.Vinyl.Curry as V
27+import qualified Data.Text as T
28+import Control.Monad.Reader
29+import Data.Hashable
30+
31+-- fuzz :: forall api.
32+-- (FlattenServer api, ToReifiedApi (Endpoints api)) =>
33+-- Server api ->
34+-- Config ->
35+-- IO (Maybe Report)
36+-- fuzz s = fuzz' (reifyServer s)
37+-- -- todo: how do we pull reifyServer out?
38+-- where reifyServer :: (FlattenServer api, ToReifiedApi (Endpoints api))
39+-- => Server api -> ReifiedApi
40+-- reifyServer server = toReifiedApi (flattenServer @api server) (Proxy @(Endpoints api))
41+
42+fuzz :: forall api . (ToReifiedClientApi (Endpoints api), FlattenClient api, HasClient ClientM api)
43+ => ClientEnv -> Config -> IO (Maybe Report)
44+fuzz clientEnv
45+ = fuzz'
46+ (toReifiedClientApi
47+ (flattenClient @api apiClient) (Proxy @(Endpoints api)) clientEnv)
48+ where apiClient = client (Proxy @api)
49+
50+
51+
52+class ToReifiedClientApi api where
53+ toReifiedClientApi :: ClientBundled api -> Proxy api -> ClientEnv -> ReifiedApi
54+
55+data ClientBundled endpoints where
56+ AClientEndpoint :: Client ClientM endpoint -> ClientBundled endpoints -> ClientBundled (endpoint ': endpoints)
57+ NoClientEndpoints :: ClientBundled '[]
58+
59+
60+class FlattenClient api where
61+ flattenClient :: Client ClientM api -> ClientBundled (Endpoints api)
62+
63+instance
64+ ( NormalizeFunction (Client ClientM endpoint)
65+ , Normal (Client ClientM endpoint) ~ V.Curried (EndpointArgs endpoint) (ReaderT ClientEnv IO (Either InteractionError (NonEmpty (Dynamic,Int))))
66+ , ToReifiedClientApi endpoints
67+ , V.RecordCurry' (EndpointArgs endpoint)
68+ , ToReifiedEndpoint endpoint) =>
69+ ToReifiedClientApi (endpoint : endpoints) where
70+ toReifiedClientApi (endpoint `AClientEndpoint` endpoints) _ clientEnv =
71+ (0, ReifiedEndpoint
72+ { reArguments = reifiedEndpointArguments @endpoint
73+ , reEndpointFunc = foo (normalize endpoint)
74+ }
75+ )
76+ : (map . first) (+1)
77+ (toReifiedClientApi endpoints (Proxy @endpoints) clientEnv)
78+ where
79+
80+ foo :: V.Curried (EndpointArgs endpoint) (ReaderT ClientEnv IO ResultType)
81+ -> V.Curried (EndpointArgs endpoint) (IO ResultType)
82+ foo = mapCurried @(EndpointArgs endpoint) @(ReaderT ClientEnv IO ResultType) (`runReaderT` clientEnv)
83+
84+mapCurried :: forall ts a b. V.RecordCurry' ts => (a -> b) -> V.Curried ts a -> V.Curried ts b
85+mapCurried f g = V.rcurry' @ts $ f . V.runcurry' g
86+
87+type ResultType = Either InteractionError (NonEmpty (Dynamic,Int))
88+-- runClientM :: ClientM a -> ClientEnv -> IO (Either ServantError a)
89+
90+
91+instance (Typeable x, Hashable x, Breakdown x) => NormalizeFunction (ClientM x) where
92+ type Normal (ClientM x) = ReaderT ClientEnv IO (Either InteractionError (NonEmpty (Dynamic,Int)))
93+ normalize c = ReaderT $
94+ fmap (bimap (InteractionError . T.pack . show) breakdown) . runClientM c
95+--
96+
97+instance ToReifiedClientApi '[] where
98+ toReifiedClientApi NoClientEndpoints _ _ = []
99+
100+
101+instance
102+ ( FlattenClient api,
103+ Endpoints endpoint ~ '[endpoint]
104+ ) =>
105+ FlattenClient (endpoint :<|> api)
106+ where
107+ flattenClient (endpoint :<|> c) = endpoint `AClientEndpoint` flattenClient @api c
108+
109+instance
110+ (
111+ Endpoints api ~ '[api]
112+ ) =>
113+ FlattenClient (x :> api)
114+ where
115+ flattenClient c = c `AClientEndpoint` NoClientEndpoints
116+
117+instance FlattenClient (Verb method statusCode contentTypes responseType)
118+ where
119+ flattenClient c = c `AClientEndpoint` NoClientEndpoints
src/Roboservant/Direct.hs+19−19
@@ -12,7 +12,7 @@
1212{-# LANGUAGE UndecidableInstances #-}
1313
1414module Roboservant.Direct
15− ( fuzz,
15+ ( fuzz',
1616 Config (..),
1717 -- TODO come up with something smarter than exporting all this, we should
1818 -- have some nice error-display functions
@@ -56,16 +56,16 @@ import GHC.Generics ((:*:) (..))
5656import Roboservant.Types
5757 ( ApiOffset (..),
5858 Argument (..),
59− FlattenServer (..),
59+ InteractionError,
6060 Provenance (..),
61+ ReifiedApi,
6162 ReifiedEndpoint (..),
6263 Stash (..),
6364 StashValue (..),
64− ToReifiedApi (..),
6565 TypedF,
6666 )
6767import Roboservant.Types.Config
68−import Servant (Endpoints, Proxy (Proxy), Server, ServerError (..))
68+
6969import System.Random (Random (randomR), StdGen, mkStdGen)
7070import qualified Type.Reflection as R
7171
@@ -105,7 +105,7 @@ data EndpointOption
105105 = forall as.
106106 (V.RecordToList as, V.RMap as) =>
107107 EndpointOption
108− { eoCall :: V.Curried as (IO (Either ServerError (NonEmpty (Dynamic, Int)))),
108+ { eoCall :: V.Curried as (IO (Either InteractionError (NonEmpty (Dynamic, Int)))),
109109 eoArgs :: V.Rec (TypedF StashValue) as
110110 }
111111
@@ -121,13 +121,18 @@ data Report
121121 }
122122 deriving (Show)
123123
124−fuzz ::
125− forall api.
126− (FlattenServer api, ToReifiedApi (Endpoints api)) =>
127− Server api ->
124+
125+
126+-- fuzzClient :: Client api -> Config -> IO (Maybe Report)
127+-- fuzzClient = undefined
128+
129+
130+
131+fuzz' ::
132+ ReifiedApi ->
128133 Config ->
129134 IO (Maybe Report)
130−fuzz server Config {..} = handle (pure . Just . formatException) $ do
135+fuzz' reifiedApi Config {..} = handle (pure . Just . formatException) $ do
131136 let path = []
132137 stash = addToStash seed mempty
133138 currentRng = mkStdGen rngSeed
@@ -173,7 +178,7 @@ fuzz server Config {..} = handle (pure . Just . formatException) $ do
173178 else do
174179 _ <- action
175180 untilDone (n -1, deadline) action
176− reifiedApi = toReifiedApi (flattenServer @api server) (Proxy @(Endpoints api))
181+
177182 routeCount = length reifiedApi
178183 elementOrFail ::
179184 (MonadState FuzzState m, MonadIO m) =>
@@ -190,7 +195,7 @@ fuzz server Config {..} = handle (pure . Just . formatException) $ do
190195 ( forall as.
191196 (V.RecordToList as, V.RMap as) =>
192197 FuzzOp ->
193− V.Curried as (IO (Either ServerError (NonEmpty (Dynamic, Int)))) ->
198+ V.Curried as (IO (Either InteractionError (NonEmpty (Dynamic, Int)))) ->
194199 V.Rec (TypedF V.Identity) as ->
195200 m r
196201 ) ->
@@ -231,18 +236,13 @@ fuzz server Config {..} = handle (pure . Just . formatException) $ do
231236 execute ::
232237 (MonadState FuzzState m, MonadIO m, V.RecordToList as, V.RMap as) =>
233238 FuzzOp ->
234− V.Curried as (IO (Either ServerError (NonEmpty (Dynamic, Int)))) ->
239+ V.Curried as (IO (Either InteractionError (NonEmpty (Dynamic, Int)))) ->
235240 V.Rec (TypedF V.Identity) as ->
236241 m ()
237242 execute fuzzop func args = do
238243 (liftIO . logInfo . show . (fuzzop,) . stash) =<< get
239244 liftIO (V.runcurry' func argVals) >>= \case
240− -- parameterise this
241− Left (serverError :: ServerError) ->
242− case errHTTPCode serverError of
243− 500 -> throw serverError
244− _ ->
245− liftIO . logInfo . show $ ("ignoring non-500 error", serverError)
245+ Left (e::InteractionError) -> throw e
246246 Right (dyn :: NEL.NonEmpty (Dynamic, Int)) -> do
247247 modify'
248248 ( \fs@FuzzState {..} ->
src/Roboservant/Server.hsnew file+36
@@ -0,0 +1,36 @@
1+{-# LANGUAGE RankNTypes #-}
2+{-# LANGUAGE TypeApplications #-}
3+{-# LANGUAGE PolyKinds #-}
4+{-# LANGUAGE AllowAmbiguousTypes #-}
5+{-# LANGUAGE FlexibleContexts #-}
6+{-# LANGUAGE GADTs #-}
7+{-# LANGUAGE PolyKinds #-}
8+{-# LANGUAGE RankNTypes #-}
9+{-# LANGUAGE ScopedTypeVariables #-}
10+{-# LANGUAGE TypeApplications #-}
11+{-# LANGUAGE TypeFamilies #-}
12+{-# LANGUAGE UndecidableInstances #-}
13+
14+module Roboservant.Server (fuzz, module Roboservant.Types ) where
15+
16+import Roboservant.Direct(fuzz',Report)
17+import Roboservant.Types
18+ ( FlattenServer (..),
19+ ReifiedApi,
20+ )
21+import Roboservant.Types.ReifiedApi.Server(ToReifiedApi (..))
22+import Servant (Endpoints, Proxy (Proxy), Server)
23+import Roboservant.Types.Config
24+
25+fuzz :: forall api.
26+ (FlattenServer api, ToReifiedApi (Endpoints api)) =>
27+ Server api ->
28+ Config ->
29+ IO (Maybe Report)
30+fuzz s = fuzz' (reifyServer s)
31+ -- todo: how do we pull reifyServer out?
32+ where reifyServer :: (FlattenServer api, ToReifiedApi (Endpoints api))
33+ => Server api -> ReifiedApi
34+ reifyServer server = toReifiedApi (flattenServer @api server) (Proxy @(Endpoints api))
35+-- reifyServer server = toReifiedApi server (Proxy @(Endpoints api))
36+
src/Roboservant/Types.hs+2−2
@@ -10,8 +10,8 @@
1010module Roboservant.Types
1111 ( module Roboservant.Types.Breakdown,
1212 module Roboservant.Types.BuildFrom,
13− module Roboservant.Types.FlattenServer,
1413 module Roboservant.Types.ReifiedApi,
14+ module Roboservant.Types.ReifiedApi.Server,
1515 module Roboservant.Types.Internal,
1616 module Roboservant.Types.Config,
1717 )
@@ -20,6 +20,6 @@ where
2020import Roboservant.Types.Breakdown
2121import Roboservant.Types.BuildFrom
2222import Roboservant.Types.Config
23−import Roboservant.Types.FlattenServer
2423import Roboservant.Types.Internal
2524import Roboservant.Types.ReifiedApi
25+import Roboservant.Types.ReifiedApi.Server
src/Roboservant/Types/BuildFrom.hs−1
@@ -12,7 +12,6 @@
1212
1313module Roboservant.Types.BuildFrom where
1414
15−import Control.Monad (filterM)
1615import qualified Data.Dependent.Map as DM
1716import Data.Hashable
1817import qualified Data.IntSet as IntSet
src/Roboservant/Types/FlattenServer.hsdeleted−40
@@ -1,40 +0,0 @@
1−{-# LANGUAGE AllowAmbiguousTypes #-}
2−{-# LANGUAGE DataKinds #-}
3−{-# LANGUAGE GADTs #-}
4−{-# LANGUAGE PolyKinds #-}
5−{-# LANGUAGE ScopedTypeVariables #-}
6−{-# LANGUAGE TypeApplications #-}
7−{-# LANGUAGE TypeOperators #-}
8−{-# LANGUAGE UndecidableInstances #-}
9−
10−-- | terrible name, this really just pulls stuff out where we can fiddle with it.
11−module Roboservant.Types.FlattenServer where
12−
13−import Servant
14−
15−data Bundled endpoints where
16− AnEndpoint :: Server endpoint -> Bundled endpoints -> Bundled (endpoint ': endpoints)
17− NoEndpoints :: Bundled '[]
18−
19−class FlattenServer api where
20− flattenServer :: Server api -> Bundled (Endpoints api)
21−
22−instance
23− ( FlattenServer api,
24− Endpoints endpoint ~ '[endpoint]
25− ) =>
26− FlattenServer (endpoint :<|> api)
27− where
28− flattenServer (endpoint :<|> server) = endpoint `AnEndpoint` flattenServer @api server
29−
30−instance
31− (
32− Endpoints api ~ '[api]
33− ) =>
34− FlattenServer (x :> api)
35− where
36− flattenServer server = server `AnEndpoint` NoEndpoints
37−
38−instance FlattenServer (Verb method statusCode contentTypes responseType)
39− where
40− flattenServer server = server `AnEndpoint` NoEndpoints
src/Roboservant/Types/ReifiedApi.hs+17−49
@@ -1,3 +1,8 @@
1+-- | Description: ways to build a reified api from a servant description.
2+--
3+-- arguably this could be more general and be abstracted away from even relying on servant
4+-- but that's future work.
5+
16{-# LANGUAGE AllowAmbiguousTypes #-}
27{-# LANGUAGE DataKinds #-}
38{-# LANGUAGE DerivingStrategies #-}
@@ -15,25 +20,22 @@
1520{-# LANGUAGE CPP #-}
1621module Roboservant.Types.ReifiedApi where
1722
18−import Control.Monad.Except (runExceptT)
19−import Data.Bifunctor
2023import Data.Dynamic (Dynamic)
21−import Data.Kind
24+import Control.Exception(Exception)
2225import Data.List.NonEmpty (NonEmpty)
2326import Data.Typeable (Typeable)
2427import GHC.Generics ((:*:)(..))
25−import GHC.TypeLits (Symbol)
2628import Roboservant.Types.Internal
2729import Roboservant.Types.Breakdown
2830import Roboservant.Types.BuildFrom
29−import Roboservant.Types.FlattenServer
31+import Data.Kind
3032import Servant
3133import Servant.API.Modifiers(FoldRequired,FoldLenient)
34+import GHC.TypeLits (Symbol)
3235import qualified Data.Text as T
3336import qualified Data.Vinyl as V
3437import qualified Data.Vinyl.Curry as V
3538import qualified Type.Reflection as R
36−import Data.Hashable(Hashable)
3739
3840newtype ApiOffset = ApiOffset Int
3941 deriving (Eq, Show, Ord)
@@ -47,18 +49,9 @@ newtype Argument a = Argument
4749
4850data ReifiedEndpoint = forall as. (V.RecordToList as, V.RMap as) => ReifiedEndpoint
4951 { reArguments :: V.Rec (TypedF Argument) as
50− , reEndpointFunc :: V.Curried as (IO (Either ServerError (NonEmpty (Dynamic,Int))))
52+ , reEndpointFunc :: V.Curried as (IO (Either InteractionError (NonEmpty (Dynamic,Int))))
5153 }
5254
53−type ReifiedApi = [(ApiOffset, ReifiedEndpoint)]
54−
55−tagType :: Typeable a => f a -> TypedF f a
56−tagType = (R.typeRep :*:)
57−
58−class ToReifiedApi (endpoints ) where
59−
60− toReifiedApi :: Bundled endpoints -> Proxy endpoints -> ReifiedApi
61−
6255class ( V.RecordToList (EndpointArgs endpoint)
6356 , V.RMap (EndpointArgs endpoint)
6457 ) => ToReifiedEndpoint (endpoint :: *) where
@@ -67,41 +60,16 @@ class ( V.RecordToList (EndpointArgs endpoint)
6760
6861 reifiedEndpointArguments :: V.Rec (TypedF Argument) (EndpointArgs endpoint)
6962
70−instance ToReifiedApi '[] where
71− toReifiedApi NoEndpoints _ = []
7263
73−instance
74− ( Typeable (EndpointRes endpoint)
64+tagType :: Typeable a => f a -> TypedF f a
65+tagType = (R.typeRep :*:)
66+
67+newtype InteractionError = InteractionError T.Text
68+ deriving Show
69+instance Exception InteractionError
70+
71+
7572
76− , NormalizeFunction (ServerT endpoint Handler)
77− , Normal (ServerT endpoint Handler) ~ V.Curried (EndpointArgs endpoint) (IO (Either ServerError (NonEmpty (Dynamic,Int))))
78− , ToReifiedEndpoint endpoint
79− , ToReifiedApi endpoints
80− ) =>
81− ToReifiedApi (endpoint : endpoints)
82− where
83− toReifiedApi (endpoint `AnEndpoint` endpoints) _ =
84− (0, ReifiedEndpoint
85− { reArguments = reifiedEndpointArguments @endpoint
86− , reEndpointFunc = normalize endpoint
87− }
88− )
89− : (map . first) (+1)
90− (toReifiedApi endpoints (Proxy @endpoints))
91−
92−class NormalizeFunction m where
93− type Normal m
94− normalize :: m -> Normal m
95−
96−instance NormalizeFunction x => NormalizeFunction (r -> x) where
97− type Normal (r -> x) = r -> Normal x
98− normalize = fmap normalize
99−
100−instance (Typeable x, Hashable x, Breakdown x) => NormalizeFunction (Handler x) where
101− type Normal (Handler x) = IO (Either ServerError (NonEmpty (Dynamic,Int)))
102− normalize handler = (runExceptT . runHandler') handler >>= \case
103− Left serverError -> pure (Left serverError)
104− Right x -> pure $ Right $ breakdown x
10573
10674instance
10775 (Typeable responseType, Breakdown responseType) =>
src/Roboservant/Types/ReifiedApi/Server.hsnew file+113
@@ -0,0 +1,113 @@
1+{-# LANGUAGE RankNTypes #-}
2+{-# LANGUAGE TypeApplications #-}
3+{-# LANGUAGE PolyKinds #-}
4+{-# LANGUAGE AllowAmbiguousTypes #-}
5+{-# LANGUAGE FlexibleContexts #-}
6+{-# LANGUAGE GADTs #-}
7+{-# LANGUAGE LambdaCase #-}
8+{-# LANGUAGE PolyKinds #-}
9+{-# LANGUAGE RankNTypes #-}
10+{-# LANGUAGE ScopedTypeVariables #-}
11+{-# LANGUAGE TypeApplications #-}
12+{-# LANGUAGE TypeFamilies #-}
13+{-# LANGUAGE UndecidableInstances #-}
14+{-# LANGUAGE CPP #-}
15+{-# LANGUAGE DataKinds #-}
16+{-# LANGUAGE TypeOperators #-}
17+{-# LANGUAGE FlexibleInstances #-}
18+{-# LANGUAGE MultiParamTypeClasses #-}
19+module Roboservant.Types.ReifiedApi.Server(module Roboservant.Types.ReifiedApi.Server) where
20+
21+import Servant
22+
23+import Control.Monad.Except (runExceptT)
24+import Data.Bifunctor
25+import Data.Dynamic (Dynamic)
26+import Data.List.NonEmpty (NonEmpty)
27+import Data.Typeable (Typeable)
28+import Roboservant.Types.Breakdown
29+import Roboservant.Types.ReifiedApi
30+
31+import qualified Data.Text as T
32+import qualified Data.Vinyl.Curry as V
33+import Data.Hashable(Hashable)
34+
35+type ReifiedApi = [(ApiOffset, ReifiedEndpoint )]
36+
37+
38+class ToReifiedApi endpoints where
39+ toReifiedApi :: Bundled endpoints -> Proxy endpoints -> ReifiedApi
40+
41+instance ToReifiedApi '[] where
42+ toReifiedApi NoEndpoints _ = []
43+
44+instance
45+ ( NormalizeFunction (ServerT endpoint Handler)
46+ , Normal (ServerT endpoint Handler) ~ V.Curried (EndpointArgs endpoint) (IO (Either InteractionError (NonEmpty (Dynamic,Int))))
47+ , ToReifiedEndpoint endpoint
48+ , ToReifiedApi endpoints
49+ ) =>
50+ ToReifiedApi (endpoint : endpoints)
51+ where
52+ toReifiedApi (endpoint `AnEndpoint` endpoints) _ =
53+ (0, ReifiedEndpoint
54+ { reArguments = reifiedEndpointArguments @endpoint
55+ , reEndpointFunc = normalize endpoint
56+ }
57+ )
58+ : (map . first) (+1)
59+ (toReifiedApi endpoints (Proxy @endpoints))
60+
61+
62+instance (Typeable x, Hashable x, Breakdown x) => NormalizeFunction (Handler x) where
63+ type Normal (Handler x) = IO (Either InteractionError (NonEmpty (Dynamic,Int)))
64+ normalize handler = (runExceptT . runHandler') handler >>= \case
65+ Left serverError -> pure (Left (renderServerError serverError))
66+ where
67+ -- | TODO improve this
68+ renderServerError :: ServerError -> InteractionError
69+ renderServerError = InteractionError . T.pack . show
70+ Right x -> pure $ Right $ breakdown x
71+
72+
73+-- case errHTTPCode serverError of
74+-- 500 -> throw serverError
75+-- _ ->
76+-- liftIO . logInfo . show $ ("ignoring non-500 error", serverError)
77+
78+
79+data Bundled endpoints where
80+ -- AnEndpoint :: Server endpoint -> Bundled endpoints -> Bundled (endpoint ': endpoints)
81+ AnEndpoint :: Server endpoint -> Bundled endpoints -> Bundled (endpoint ': endpoints)
82+ NoEndpoints :: Bundled '[]
83+
84+class FlattenServer api where
85+ flattenServer :: Server api -> Bundled (Endpoints api)
86+
87+instance
88+ ( FlattenServer api,
89+ Endpoints endpoint ~ '[endpoint]
90+ ) =>
91+ FlattenServer (endpoint :<|> api)
92+ where
93+ flattenServer (endpoint :<|> server) = endpoint `AnEndpoint` flattenServer @api server
94+
95+instance
96+ (
97+ Endpoints api ~ '[api]
98+ ) =>
99+ FlattenServer (x :> api)
100+ where
101+ flattenServer server = server `AnEndpoint` NoEndpoints
102+
103+instance FlattenServer (Verb method statusCode contentTypes responseType)
104+ where
105+ flattenServer server = server `AnEndpoint` NoEndpoints
106+
107+class NormalizeFunction m where
108+ type Normal m
109+ normalize :: m -> Normal m
110+
111+instance NormalizeFunction x => NormalizeFunction (r -> x) where
112+ type Normal (r -> x) = r -> Normal x
113+ normalize = fmap normalize
test/Spec.hs+82−56
@@ -4,7 +4,13 @@
44{-# LANGUAGE RecordWildCards #-}
55{-# LANGUAGE StandaloneDeriving #-}
66{-# LANGUAGE TypeApplications #-}
7+{-# LANGUAGE RankNTypes #-}
8+{-# LANGUAGE FlexibleContexts #-}
9+{-# LANGUAGE AllowAmbiguousTypes #-}
10+{-# LANGUAGE ScopedTypeVariables #-}
11+{-# LANGUAGE DataKinds #-}
712{-# OPTIONS_GHC -fno-warn-orphans #-}
13+{-# LANGUAGE TypeFamilies #-}
814
915import qualified Breakdown
1016import Data.Dynamic (toDyn)
@@ -16,99 +22,119 @@ import qualified Headers
1622import qualified Nested
1723import qualified Post
1824import qualified Product
19−import qualified Roboservant as RS
25+import qualified Roboservant as R
26+import qualified Roboservant.Server as RS
27+import qualified Roboservant.Client as RC
2028import qualified Seeded
2129import Test.Hspec
2230import Test.Hspec.Core.Spec (FailureReason (Reason), ResultStatus (Failure, Success), itemExample, mapSpecItem_, resultStatus)
2331import qualified Valid
32+import Servant ( Server, Proxy(..), serve, Endpoints, HasServer )
33+
34+import Servant.Client(ClientEnv, mkClientEnv, baseUrlPort, parseBaseUrl,HasClient,ClientM)
35+import Network.Wai(Application)
36+import qualified Network.Wai.Handler.Warp as Warp
37+import Network.HTTP.Client (newManager, defaultManagerSettings)
38+import Servant(Endpoints,HasServer)
2439
2540main :: IO ()
2641main = hspec spec
2742
43+fuzzBoth
44+ :: forall a .
45+ (R.ToReifiedApi (Endpoints a), HasServer a '[], RS.FlattenServer a, RC.ToReifiedClientApi (Endpoints a), RC.FlattenClient a,
46+ HasClient ClientM a)
47+ => String -> Server a -> R.Config -> (Maybe R.Report -> IO ()) -> Spec
48+fuzzBoth name server config condition = do
49+ it (name <> " via server") $ do
50+ RS.fuzz @a server config >>= condition
51+
52+ around (withServer (serve (Proxy :: Proxy a) server)) $ do
53+ it (name <> " via client") $ \(clientEnv::ClientEnv) -> do
54+ RC.fuzz @a clientEnv config >>= condition
55+
56+withServer :: Application -> ActionWith ClientEnv -> IO ()
57+withServer app action = Warp.testWithApplication (pure app) (\p -> genClientEnv p >>= action)
58+ where genClientEnv port = do
59+ baseUrl <- parseBaseUrl "http://localhost"
60+ manager <- newManager defaultManagerSettings
61+ pure $ mkClientEnv manager (baseUrl { baseUrlPort = port })
62+
2863spec :: Spec
2964spec = do
3065 describe "Basic usage" $ do
3166 describe "noError" $ do
32− it "finds no error in a valid app" $ do
33− RS.fuzz @Valid.Api Valid.server RS.defaultConfig
34− >>= (`shouldSatisfy` isNothing)
35− it "finds no error in a valid generic app" $ do
36− RS.fuzz @Valid.RoutedApi Valid.routedServer RS.defaultConfig
37− >>= (`shouldSatisfy` isNothing)
38− it "does fail coverage check" $ do
39− r <- RS.fuzz @Valid.Api Valid.server RS.defaultConfig {RS.coverageThreshold = 0.6}
40− fmap (RS.failureReason . RS.rsException) r
41− `shouldSatisfy` ( \case
42− Just (RS.InsufficientCoverage _) -> True
43− _ -> False
44− )
67+ fuzzBoth @Valid.Api "find no error in a basic app" Valid.server R.defaultConfig (`shouldSatisfy` isNothing)
68+ fuzzBoth @Valid.RoutedApi "finds no error in a valid generic app" Valid.routedServer R.defaultConfig (`shouldSatisfy` isNothing)
69+ fuzzBoth @Valid.Api "fails coverage check" Valid.server R.defaultConfig {R.coverageThreshold = 0.6}
70+ (\r ->
71+ fmap (R.failureReason . R.rsException) r
72+ `shouldSatisfy` ( \case
73+ Just (R.InsufficientCoverage _) -> True
74+ _ -> False
75+ ))
4576 describe "posted body" $ do
46− it "passes a coverage check using a posted body" $ do
47− RS.fuzz @Post.Api Post.server RS.defaultConfig {RS.coverageThreshold = 0.99}
48− >>= (`shouldSatisfy` isNothing)
77+ fuzzBoth @Post.Api "passes a coverage check using a posted body" Post.server R.defaultConfig {R.coverageThreshold = 0.99}
78+ (`shouldSatisfy` isNothing)
79+
4980 describe "seeded" $ do
50− shouldFail
51− $ it "finds an error using information passed in"
52− $ let res = Seeded.Seed 1
53− in RS.fuzz @Seeded.Api Seeded.server (RS.defaultConfig {RS.seed = [(toDyn res, hash res)]})
54− >>= (`shouldSatisfy` isNothing)
81+ let res = Seeded.Seed 1
82+ shouldFail $ fuzzBoth @Seeded.Api "finds an error using information passed in" Seeded.server
83+ (R.defaultConfig {R.seed = [(toDyn res, hash res)]})
84+ (`shouldSatisfy` isNothing)
85+
5586 describe "Foo" $ do
56− it "finds an error in a basic app" $
57− RS.fuzz @Foo.Api Foo.server RS.defaultConfig
58− >>= (`shouldSatisfy` serverFailure)
87+ fuzzBoth @Foo.Api "finds an error in a basic app" Foo.server R.defaultConfig (`shouldSatisfy` serverFailure)
88+
5989 describe "BuildFrom" $ do
6090 describe "headers (and sum types)" $ do
61− it "should find a failure that's dependent on using header info" $ do
62− RS.fuzz @Headers.Api Headers.server RS.defaultConfig
63− >>= (`shouldSatisfy` serverFailure)
91+ fuzzBoth @Headers.Api "should find a failure that's dependent on using header info" Headers.server R.defaultConfig
92+ (`shouldSatisfy` serverFailure)
6493 describe "product types" $ do
65− it "should find a failure that's dependent on creating a product" $ do
66− RS.fuzz @Product.Api Product.server RS.defaultConfig {RS.seed = [RS.hashedDyn 'a', RS.hashedDyn (1 :: Int)]}
67− >>= (`shouldSatisfy` serverFailure)
94+ fuzzBoth @Product.Api "should find a failure that's dependent on creating a product" Product.server
95+ R.defaultConfig {R.seed = [R.hashedDyn 'a', R.hashedDyn (1 :: Int)]}
96+ (`shouldSatisfy` serverFailure)
6897 describe "Breakdown" $ do
69− it "handles products" $ do
70− RS.fuzz @Breakdown.ProductApi Breakdown.productServer RS.defaultConfig
71− >>= (`shouldSatisfy` serverFailure)
72− it "handles sums" $ do
73− RS.fuzz @Breakdown.SumApi Breakdown.sumServer RS.defaultConfig
74− >>= (`shouldSatisfy` serverFailure)
98+ fuzzBoth @Breakdown.ProductApi "handles products" Breakdown.productServer R.defaultConfig
99+ (`shouldSatisfy` serverFailure)
100+ fuzzBoth @Breakdown.SumApi "handles sums" Breakdown.sumServer R.defaultConfig
101+ (`shouldSatisfy` serverFailure)
75102 describe "flattening" $ do
76103 -- we don't actually do much here, this is just here to document the appropriate response
77104 -- if you get a type error with a nested api.
78− it "can handle nested apis" $ do
79− RS.fuzz @(Nested.FlatApi) Nested.server RS.defaultConfig {RS.coverageThreshold = 0.99}
80− >>= (`shouldSatisfy` isNothing)
105+ fuzzBoth @Nested.FlatApi "can handle nested apis" Nested.server R.defaultConfig {R.coverageThreshold = 0.99}
106+ (`shouldSatisfy` isNothing)
81107
82−serverFailure :: Maybe RS.Report -> Bool
108+serverFailure :: Maybe R.Report -> Bool
83109serverFailure = \case
84− Just RS.Report {..} ->
85− let RS.RoboservantException {..} = rsException
86− in failureReason /= RS.NoPossibleMoves
110+ Just R.Report {..} ->
111+ let R.RoboservantException {..} = rsException
112+ in failureReason /= R.NoPossibleMoves
87113 _ -> False
88114
89−deriving via (RS.Atom Foo.Foo) instance RS.Breakdown Foo.Foo
115+deriving via (R.Atom Foo.Foo) instance R.Breakdown Foo.Foo
90116
91−deriving via (RS.Atom Foo.Foo) instance RS.BuildFrom Foo.Foo
117+deriving via (R.Atom Foo.Foo) instance R.BuildFrom Foo.Foo
92118
93−deriving via (RS.Atom Headers.Foo) instance RS.Breakdown Headers.Foo
119+deriving via (R.Atom Headers.Foo) instance R.Breakdown Headers.Foo
94120
95−deriving via (RS.Atom Headers.Foo) instance RS.BuildFrom Headers.Foo
121+deriving via (R.Atom Headers.Foo) instance R.BuildFrom Headers.Foo
96122
97−deriving via (RS.Atom Seeded.Seed) instance RS.Breakdown Seeded.Seed
123+deriving via (R.Atom Seeded.Seed) instance R.Breakdown Seeded.Seed
98124
99−deriving via (RS.Atom Seeded.Seed) instance RS.BuildFrom Seeded.Seed
125+deriving via (R.Atom Seeded.Seed) instance R.BuildFrom Seeded.Seed
100126
101−deriving via (RS.Atom Void) instance RS.BuildFrom Void
127+deriving via (R.Atom Void) instance R.BuildFrom Void
102128
103−deriving via (RS.Atom Post.FooPost) instance RS.Breakdown Post.FooPost
129+deriving via (R.Atom Post.FooPost) instance R.Breakdown Post.FooPost
104130
105−deriving via (RS.Atom Post.FooPost) instance RS.BuildFrom Post.FooPost
131+deriving via (R.Atom Post.FooPost) instance R.BuildFrom Post.FooPost
106132
107−deriving via (RS.Compound Breakdown.Foo) instance RS.Breakdown Breakdown.Foo
133+deriving via (R.Compound Breakdown.Foo) instance R.Breakdown Breakdown.Foo
108134
109−deriving via (RS.Compound Product.Foo) instance RS.BuildFrom Product.Foo
135+deriving via (R.Compound Product.Foo) instance R.BuildFrom Product.Foo
110136
111−deriving via (RS.Compound Breakdown.SomeSum) instance RS.Breakdown Breakdown.SomeSum
137+deriving via (R.Compound Breakdown.SomeSum) instance R.Breakdown Breakdown.SomeSum
112138
113139-- | `shouldFail` allows you to assert that a given `Spec` should contain at least one failing test.
114140-- this is often useful when testing tests.