on master help sign in
Merge pull request #1005 from haskell-servant/using-free-client

Add 'using free client' recipe

commitbd8c5b96c3378e856b7ee9aa1ec35a969836ef07 authorOleg Grenrus <oleg.grenrus@iki.fi> dateFri 6 Jul 2018, 04:18 (8 years ago) parentf536c904eca451
5 files changed+236−6
.travis.yml+14−6 cabal.project+1 doc/cookbook/index.rst+1 doc/cookbook/using-free-client/UsingFreeClient.lhs+194 doc/cookbook/using-free-client/using-free-client.cabal+26
.travis.yml+14−6
@@ -72,11 +72,11 @@ install:
7272 - rm -fv cabal.project cabal.project.local
7373 - "if [ $HCNUMVER -ge 70800 ]; then sed -i.bak 's/-- ghc-options:.*/ghc-options: -j2/' ${HOME}/.cabal/config; fi"
7474 - grep -Ev -- '^\s*--' ${HOME}/.cabal/config | grep -Ev '^\s*$'
75− - "printf 'packages: \"servant\" \"servant-client\" \"servant-client-core\" \"servant-docs\" \"servant-foreign\" \"servant-server\" \"doc/tutorial\" \"doc/cookbook/basic-auth\" \"doc/cookbook/db-postgres-pool\" \"doc/cookbook/db-sqlite-simple\" \"doc/cookbook/file-upload\" \"doc/cookbook/https\" \"doc/cookbook/jwt-and-basic-auth\" \"doc/cookbook/pagination\" \"doc/cookbook/structuring-apis\" \"doc/cookbook/using-custom-monad\"\\n' > cabal.project"
75+ - "printf 'packages: \"servant\" \"servant-client\" \"servant-client-core\" \"servant-docs\" \"servant-foreign\" \"servant-server\" \"doc/tutorial\" \"doc/cookbook/basic-auth\" \"doc/cookbook/db-postgres-pool\" \"doc/cookbook/db-sqlite-simple\" \"doc/cookbook/file-upload\" \"doc/cookbook/generic\" \"doc/cookbook/https\" \"doc/cookbook/jwt-and-basic-auth\" \"doc/cookbook/pagination\" \"doc/cookbook/structuring-apis\" \"doc/cookbook/using-custom-monad\" \"doc/cookbook/using-free-client\"\\n' > cabal.project"
7676 - "echo 'constraints: foundation >=0.0.14,memory <0.14.12 || >0.14.12' >> cabal.project"
7777 - "echo 'allow-newer: servant-auth-server:http-types,servant-auth-server:servant-server, servant-pagination:servant,servant-pagination:servant-server' >> cabal.project"
7878 - touch cabal.project.local
79− - "if ! $NOINSTALLEDCONSTRAINTS; then for pkg in $($HCPKG list --simple-output); do echo $pkg | sed 's/^/constraints: /' | sed 's/-[^-]*$/ installed/' >> cabal.project.local; done; fi"
79+ - "if ! $NOINSTALLEDCONSTRAINTS; then for pkg in $($HCPKG list --simple-output); do echo $pkg | grep -vw -- servant | grep -vw -- servant-client | grep -vw -- servant-client-core | grep -vw -- servant-docs | grep -vw -- servant-foreign | grep -vw -- servant-server | grep -vw -- tutorial | grep -vw -- cookbook-basic-auth | grep -vw -- cookbook-db-postgres-pool | grep -vw -- cookbook-db-sqlite-simple | grep -vw -- cookbook-file-upload | grep -vw -- cookbook-generic | grep -vw -- cookbook-https | grep -vw -- cookbook-jwt-and-basic-auth | grep -vw -- cookbook-pagination | grep -vw -- cookbook-structuring-apis | grep -vw -- cookbook-using-custom-monad | grep -vw -- cookbook-using-free-client | sed 's/^/constraints: /' | sed 's/-[^-]*$/ installed/' >> cabal.project.local; done; fi"
8080 - cat cabal.project || true
8181 - cat cabal.project.local || true
8282 - if [ -f "servant/configure.ac" ]; then
@@ -112,6 +112,9 @@ install:
112112 - if [ -f "doc/cookbook/file-upload/configure.ac" ]; then
113113 (cd "doc/cookbook/file-upload" && autoreconf -i);
114114 fi
115+ - if [ -f "doc/cookbook/generic/configure.ac" ]; then
116+ (cd "doc/cookbook/generic" && autoreconf -i);
117+ fi
115118 - if [ -f "doc/cookbook/https/configure.ac" ]; then
116119 (cd "doc/cookbook/https" && autoreconf -i);
117120 fi
@@ -127,8 +130,11 @@ install:
127130 - if [ -f "doc/cookbook/using-custom-monad/configure.ac" ]; then
128131 (cd "doc/cookbook/using-custom-monad" && autoreconf -i);
129132 fi
133+ - if [ -f "doc/cookbook/using-free-client/configure.ac" ]; then
134+ (cd "doc/cookbook/using-free-client" && autoreconf -i);
135+ fi
130136 - rm -f cabal.project.freeze
131− - rm -rf .ghc.environment.* "servant"/dist "servant-client"/dist "servant-client-core"/dist "servant-docs"/dist "servant-foreign"/dist "servant-server"/dist "doc/tutorial"/dist "doc/cookbook/basic-auth"/dist "doc/cookbook/db-postgres-pool"/dist "doc/cookbook/db-sqlite-simple"/dist "doc/cookbook/file-upload"/dist "doc/cookbook/https"/dist "doc/cookbook/jwt-and-basic-auth"/dist "doc/cookbook/pagination"/dist "doc/cookbook/structuring-apis"/dist "doc/cookbook/using-custom-monad"/dist
137+ - rm -rf .ghc.environment.* "servant"/dist "servant-client"/dist "servant-client-core"/dist "servant-docs"/dist "servant-foreign"/dist "servant-server"/dist "doc/tutorial"/dist "doc/cookbook/basic-auth"/dist "doc/cookbook/db-postgres-pool"/dist "doc/cookbook/db-sqlite-simple"/dist "doc/cookbook/file-upload"/dist "doc/cookbook/generic"/dist "doc/cookbook/https"/dist "doc/cookbook/jwt-and-basic-auth"/dist "doc/cookbook/pagination"/dist "doc/cookbook/structuring-apis"/dist "doc/cookbook/using-custom-monad"/dist "doc/cookbook/using-free-client"/dist
132138 - DISTDIR=$(mktemp -d /tmp/dist-test.XXXX)
133139
134140# Here starts the actual work to be performed for the package under test;
@@ -147,21 +153,23 @@ script:
147153 - (cd "doc/cookbook/db-postgres-pool" && cabal sdist)
148154 - (cd "doc/cookbook/db-sqlite-simple" && cabal sdist)
149155 - (cd "doc/cookbook/file-upload" && cabal sdist)
156+ - (cd "doc/cookbook/generic" && cabal sdist)
150157 - (cd "doc/cookbook/https" && cabal sdist)
151158 - (cd "doc/cookbook/jwt-and-basic-auth" && cabal sdist)
152159 - (cd "doc/cookbook/pagination" && cabal sdist)
153160 - (cd "doc/cookbook/structuring-apis" && cabal sdist)
154161 - (cd "doc/cookbook/using-custom-monad" && cabal sdist)
162+ - (cd "doc/cookbook/using-free-client" && cabal sdist)
155163 - echo -en 'travis_fold:end:sdist\\r'
156164 - echo Unpacking... && echo -en 'travis_fold:start:unpack\\r'
157− - mv "servant"/dist/servant-*.tar.gz "servant-client"/dist/servant-client-*.tar.gz "servant-client-core"/dist/servant-client-core-*.tar.gz "servant-docs"/dist/servant-docs-*.tar.gz "servant-foreign"/dist/servant-foreign-*.tar.gz "servant-server"/dist/servant-server-*.tar.gz "doc/tutorial"/dist/tutorial-*.tar.gz "doc/cookbook/basic-auth"/dist/cookbook-basic-auth-*.tar.gz "doc/cookbook/db-postgres-pool"/dist/cookbook-db-postgres-pool-*.tar.gz "doc/cookbook/db-sqlite-simple"/dist/cookbook-db-sqlite-simple-*.tar.gz "doc/cookbook/file-upload"/dist/cookbook-file-upload-*.tar.gz "doc/cookbook/https"/dist/cookbook-https-*.tar.gz "doc/cookbook/jwt-and-basic-auth"/dist/cookbook-jwt-and-basic-auth-*.tar.gz "doc/cookbook/pagination"/dist/cookbook-pagination-*.tar.gz "doc/cookbook/structuring-apis"/dist/cookbook-structuring-apis-*.tar.gz "doc/cookbook/using-custom-monad"/dist/cookbook-using-custom-monad-*.tar.gz ${DISTDIR}/
165+ - mv "servant"/dist/servant-*.tar.gz "servant-client"/dist/servant-client-*.tar.gz "servant-client-core"/dist/servant-client-core-*.tar.gz "servant-docs"/dist/servant-docs-*.tar.gz "servant-foreign"/dist/servant-foreign-*.tar.gz "servant-server"/dist/servant-server-*.tar.gz "doc/tutorial"/dist/tutorial-*.tar.gz "doc/cookbook/basic-auth"/dist/cookbook-basic-auth-*.tar.gz "doc/cookbook/db-postgres-pool"/dist/cookbook-db-postgres-pool-*.tar.gz "doc/cookbook/db-sqlite-simple"/dist/cookbook-db-sqlite-simple-*.tar.gz "doc/cookbook/file-upload"/dist/cookbook-file-upload-*.tar.gz "doc/cookbook/generic"/dist/cookbook-generic-*.tar.gz "doc/cookbook/https"/dist/cookbook-https-*.tar.gz "doc/cookbook/jwt-and-basic-auth"/dist/cookbook-jwt-and-basic-auth-*.tar.gz "doc/cookbook/pagination"/dist/cookbook-pagination-*.tar.gz "doc/cookbook/structuring-apis"/dist/cookbook-structuring-apis-*.tar.gz "doc/cookbook/using-custom-monad"/dist/cookbook-using-custom-monad-*.tar.gz "doc/cookbook/using-free-client"/dist/cookbook-using-free-client-*.tar.gz ${DISTDIR}/
158166 - cd ${DISTDIR} || false
159167 - find . -maxdepth 1 -name '*.tar.gz' -exec tar -xvf '{}' \;
160− - "printf 'packages: servant-*/*.cabal servant-client-*/*.cabal servant-client-core-*/*.cabal servant-docs-*/*.cabal servant-foreign-*/*.cabal servant-server-*/*.cabal tutorial-*/*.cabal cookbook-basic-auth-*/*.cabal cookbook-db-postgres-pool-*/*.cabal cookbook-db-sqlite-simple-*/*.cabal cookbook-file-upload-*/*.cabal cookbook-https-*/*.cabal cookbook-jwt-and-basic-auth-*/*.cabal cookbook-pagination-*/*.cabal cookbook-structuring-apis-*/*.cabal cookbook-using-custom-monad-*/*.cabal\\n' > cabal.project"
168+ - "printf 'packages: servant-*/*.cabal servant-client-*/*.cabal servant-client-core-*/*.cabal servant-docs-*/*.cabal servant-foreign-*/*.cabal servant-server-*/*.cabal tutorial-*/*.cabal cookbook-basic-auth-*/*.cabal cookbook-db-postgres-pool-*/*.cabal cookbook-db-sqlite-simple-*/*.cabal cookbook-file-upload-*/*.cabal cookbook-generic-*/*.cabal cookbook-https-*/*.cabal cookbook-jwt-and-basic-auth-*/*.cabal cookbook-pagination-*/*.cabal cookbook-structuring-apis-*/*.cabal cookbook-using-custom-monad-*/*.cabal cookbook-using-free-client-*/*.cabal\\n' > cabal.project"
161169 - "echo 'constraints: foundation >=0.0.14,memory <0.14.12 || >0.14.12' >> cabal.project"
162170 - "echo 'allow-newer: servant-auth-server:http-types,servant-auth-server:servant-server, servant-pagination:servant,servant-pagination:servant-server' >> cabal.project"
163171 - touch cabal.project.local
164− - "if ! $NOINSTALLEDCONSTRAINTS; then for pkg in $($HCPKG list --simple-output); do echo $pkg | sed 's/^/constraints: /' | sed 's/-[^-]*$/ installed/' >> cabal.project.local; done; fi"
172+ - "if ! $NOINSTALLEDCONSTRAINTS; then for pkg in $($HCPKG list --simple-output); do echo $pkg | grep -vw -- servant | grep -vw -- servant-client | grep -vw -- servant-client-core | grep -vw -- servant-docs | grep -vw -- servant-foreign | grep -vw -- servant-server | grep -vw -- tutorial | grep -vw -- cookbook-basic-auth | grep -vw -- cookbook-db-postgres-pool | grep -vw -- cookbook-db-sqlite-simple | grep -vw -- cookbook-file-upload | grep -vw -- cookbook-generic | grep -vw -- cookbook-https | grep -vw -- cookbook-jwt-and-basic-auth | grep -vw -- cookbook-pagination | grep -vw -- cookbook-structuring-apis | grep -vw -- cookbook-using-custom-monad | grep -vw -- cookbook-using-free-client | sed 's/^/constraints: /' | sed 's/-[^-]*$/ installed/' >> cabal.project.local; done; fi"
165173 - cat cabal.project || true
166174 - cat cabal.project.local || true
167175 - echo -en 'travis_fold:end:unpack\\r'
cabal.project+1
@@ -18,6 +18,7 @@ packages: servant/
1818 doc/cookbook/pagination
1919 doc/cookbook/structuring-apis
2020 doc/cookbook/using-custom-monad
21+ doc/cookbook/using-free-client
2122
2223allow-newer:
2324 servant-auth-server:http-types,
doc/cookbook/index.rst+1
@@ -23,6 +23,7 @@ you name it!
2323 db-sqlite-simple/DBConnection.lhs
2424 db-postgres-pool/PostgresPool.lhs
2525 using-custom-monad/UsingCustomMonad.lhs
26+ using-free-client/UsingFreeClient.lhs
2627 basic-auth/BasicAuth.lhs
2728 jwt-and-basic-auth/JWTAndBasicAuth.lhs
2829 file-upload/FileUpload.lhs
doc/cookbook/using-free-client/UsingFreeClient.lhsnew file+194
@@ -0,0 +1,194 @@
1+# Inspecting, debugging, simulating clients and more
2+
3+or simply put: _a practical introduction to `Servant.Client.Free`_.
4+
5+Someone asked on IRC how one could access the intermediate Requests (resp. Responses)
6+produced (resp. received) by client functions derived using servant-client.
7+My response to such inquiries is: to extend `servant-client` in an ad-hoc way (e.g for testing or debugging
8+purposes), use `Servant.Client.Free`. This recipe shows how.
9+
10+First the module header, but this time We'll comment the imports.
11+
12+```haskell
13+{-# LANGUAGE DataKinds #-}
14+{-# LANGUAGE TypeOperators #-}
15+module Main (main) where
16+```
17+
18+We will primarily use `Servant.Client.Free`, it doesn't re-export anything
19+from `free` package, so we need to import it as well.
20+
21+```haskell
22+import Control.Monad.Free
23+import Servant.Client.Free
24+```
25+
26+Also we'll use `servant-client` internals, which uses `http-client`,
27+so let's import them *qualified*
28+
29+```haskell
30+import qualified Servant.Client.Internal.HttpClient as I
31+import qualified Network.HTTP.Client as HTTP
32+```
33+
34+The rest of the imports are for a server we implement here for completeness.
35+
36+```haskell
37+import Servant
38+import Network.Wai.Handler.Warp (run)
39+import System.Environment (getArgs)
40+```
41+
42+## API & Main
43+
44+We'll work with a very simple API:
45+
46+```haskell
47+type API = "square" :> Capture "n" Int :> Get '[JSON] Int
48+
49+api :: Proxy API
50+api = Proxy
51+```
52+
53+Next we implement a `main`. If passed `"server"` it will run `server`, if passed
54+`"client"` it will run a `test` function (to be defined next). This should be
55+pretty straightforward:
56+
57+```haskell
58+main :: IO ()
59+main = do
60+ args <- getArgs
61+ case args of
62+ ("server":_) -> do
63+ putStrLn "Starting cookbook-using-free-client at http://localhost:8000"
64+ run 8000 $ serve api $ \n -> return (n * n)
65+ ("client":_) ->
66+ test
67+ _ -> do
68+ putStrLn "Try:"
69+ putStrLn "cabal new-run cookbook-using-free-client server"
70+ putStrLn "cabal new-run cookbook-using-free-client client"
71+```
72+
73+## Test
74+
75+In the client part, we will use a `Servant.Client.Free` client.
76+Because we have a single endpoint API, we'll get a single client function,
77+running in the `Free ClientF` (free) monad:
78+
79+```haskell
80+getSquare :: Int -> Free ClientF Int
81+getSquare = client api
82+```
83+
84+Such clients are "client functions without a backend", so to speak,
85+or where the backend has been abstracted out. To be more precise, `ClientF` is a functor that
86+precisely represents the operations servant-client-core needs from an http client backend.
87+So if we are to emulate one or augment what such a backend does, it will be by interpreting
88+all those operations, the way we want to. This also means we get access to the requests and
89+responses and can do anything we want with them, right when they are produced or consumed,
90+respectively.
91+
92+Next, we can write our small test. We'll pass a value to `getSquare` and inspect
93+the `Free` structure. The first three possibilities are self-explanatory:
94+
95+```haskell
96+test :: IO ()
97+test = case getSquare 42 of
98+ Pure n ->
99+ putStrLn $ "ERROR: got pure result: " ++ show n
100+ Free (Throw err) ->
101+ putStrLn $ "ERROR: got error right away: " ++ show err
102+ Free (StreamingRequest _req _k) ->
103+ putStrLn $ "ERROR: need to do streaming request" -- TODO: no Show Req :(
104+```
105+
106+We are interested in `RunRequest`, that's what client should block on:
107+
108+```haskell
109+ Free (RunRequest req k) -> do
110+```
111+
112+Then we need to prepare the context, get HTTP (connection) `Manager`
113+and `BaseUrl`:
114+
115+```haskell
116+ burl <- parseBaseUrl "http://localhost:8000"
117+ mgr <- HTTP.newManager HTTP.defaultManagerSettings
118+```
119+
120+Now we can use `servant-client`'s internals to convert servant's `Request`
121+to http-client's `Request`, and we can inspect it:
122+
123+```haskell
124+ let req' = I.requestToClientRequest burl req
125+ putStrLn $ "Making request: " ++ show req'
126+```
127+
128+`servant-client`'s request does a little more, but this is good enough for
129+our demo. We get back an http-client `Response` which we can also inspect.
130+
131+```haskell
132+ res' <- HTTP.httpLbs req' mgr
133+ putStrLn $ "Got response: " ++ show res'
134+```
135+
136+And we continue by turning http-client's `Response` into servant's `Response`,
137+and calling the continuation. We should get a `Pure` value.
138+
139+```haskell
140+ let res = I.clientResponseToResponse res'
141+
142+ case k res of
143+ Pure n ->
144+ putStrLn $ "Expected 1764, got " ++ show n
145+ _ ->
146+ putStrLn "ERROR: didn't got a response"
147+```
148+
149+So that's it. Using `Free` we can evaluate servant clients step-by-step, and
150+validate that the client functions or the HTTP client backend does what we expect
151+(e.g by printing requests/responses on the fly). In fact, using `Servant.Client.Free`
152+is a little simpler than defining a custom `RunClient` instance, especially
153+for those cases where it is handy to have the full sequence of client calls
154+and responses available for us to inspect, since `RunClient` only gives us
155+access to one `Request` or `Response` at a time.
156+
157+On the other hand, a "batch collection" of requests and/or responses can be achieved
158+with both free clients and a custom `RunClient` instance rather easily, for example
159+by using a `Writer [(Request, Response)]` monad.
160+
161+Here is an example of running our small `test` against a running server:
162+
163+```
164+Making request: Request {
165+ host = "localhost"
166+ port = 8000
167+ secure = False
168+ requestHeaders = [("Accept","application/json;charset=utf-8,application/json")]
169+ path = "/square/42"
170+ queryString = ""
171+ method = "GET"
172+ proxy = Nothing
173+ rawBody = False
174+ redirectCount = 10
175+ responseTimeout = ResponseTimeoutDefault
176+ requestVersion = HTTP/1.1
177+}
178+
179+Got response: Response
180+ { responseStatus = Status {statusCode = 200, statusMessage = "OK"}
181+ , responseVersion = HTTP/1.1
182+ , responseHeaders =
183+ [ ("Transfer-Encoding","chunked")
184+ , ("Date","Thu, 05 Jul 2018 21:12:41 GMT")
185+ , ("Server","Warp/3.2.22")
186+ , ("Content-Type","application/json;charset=utf-8")
187+ ]
188+ , responseBody = "1764"
189+ , responseCookieJar = CJ {expose = []}
190+ , responseClose' = ResponseClose
191+ }
192+
193+Expected 1764, got 1764
194+```
doc/cookbook/using-free-client/using-free-client.cabalnew file+26
@@ -0,0 +1,26 @@
1+name: cookbook-using-free-client
2+version: 0.1
3+synopsis: Using Free client
4+homepage: http://haskell-servant.readthedocs.org/
5+license: BSD3
6+license-file: ../../../servant/LICENSE
7+author: Servant Contributors
8+maintainer: haskell-servant-maintainers@googlegroups.com
9+build-type: Simple
10+cabal-version: >=1.10
11+tested-with: GHC==7.8.4, GHC==7.10.3, GHC==8.0.2, GHC==8.2.2, GHC==8.4.3
12+
13+executable cookbook-using-free-client
14+ main-is: UsingFreeClient.lhs
15+ build-depends: base == 4.*
16+ , free
17+ , servant
18+ , servant-client
19+ , http-client
20+ , servant-client-core
21+ , base-compat
22+ , servant-server
23+ , warp >= 3.2
24+ default-language: Haskell2010
25+ ghc-options: -Wall -pgmL markdown-unlit
26+ build-tool-depends: markdown-unlit:markdown-unlit >= 0.4