git.iko.soy/typeable/servant
on 5fa99be help sign in
typeable/servant/doc/tutorial/Javascript.lhs
518 lines 13 kB history raw
1# Generating Javascript functions to query an API
3We will now see how **servant** lets you turn an API type into javascript
4functions that you can call to query a webservice.
6For this, we will consider a simple page divided in two parts. At the top, we
7will have a search box that lets us search in a list of Haskell books by
8author/title with a list of results that gets updated every time we enter or
9remove a character, while at the bottom we will be able to see the classical
10[probabilistic method to approximate
11pi](http://en.wikipedia.org/wiki/Approximations_of_%CF%80#Summing_a_circle.27s_area),
12using a webservice to get random points. Finally, we will serve an HTML file
13along with a couple of Javascript files, among which one that's automatically
14generated from the API type and which will provide ready-to-use functions to
15query your API.
17The source for this tutorial section is a literate haskell file, so first we
18need to have some language extensions and imports:
20``` haskell
21{-# LANGUAGE DataKinds #-}
22{-# LANGUAGE DeriveGeneric #-}
23{-# LANGUAGE OverloadedStrings #-}
24{-# LANGUAGE TypeOperators #-}
26module Javascript where
28import Control.Monad.IO.Class
29import Data.Aeson
30import Data.Proxy
31import Data.Text as T (Text)
32import Data.Text.IO as T (writeFile, readFile)
33import GHC.Generics
34import Language.Javascript.JQuery
35import Network.Wai
36import Network.Wai.Handler.Warp
37import qualified Data.Text as T
38import Servant
39import Servant.JS
40import System.Random
41```
43Now let's have the API type(s) and the accompanying datatypes.
45``` haskell
46type API = "point" :> Get '[JSON] Point
47 :<|> "books" :> QueryParam "q" Text :> Get '[JSON] (Search Book)
49type API' = API :<|> Raw
51data Point = Point
52 { x :: Double
53 , y :: Double
54 } deriving Generic
56instance ToJSON Point
58data Search a = Search
59 { query :: Text
60 , results :: [a]
61 } deriving Generic
63mkSearch :: Text -> [a] -> Search a
64mkSearch = Search
66instance ToJSON a => ToJSON (Search a)
68data Book = Book
69 { author :: Text
70 , title :: Text
71 , year :: Int
72 } deriving Generic
74instance ToJSON Book
76book :: Text -> Text -> Int -> Book
77book = Book
78```
80We need a "book database". For the purpose of this guide, let's restrict
81ourselves to the following books.
83``` haskell
84books :: [Book]
85books =
86 [ book "Paul Hudak" "The Haskell School of Expression: Learning Functional Programming through Multimedia" 2000
87 , book "Bryan O'Sullivan, Don Stewart, and John Goerzen" "Real World Haskell" 2008
88 , book "Miran Lipovača" "Learn You a Haskell for Great Good!" 2011
89 , book "Graham Hutton" "Programming in Haskell" 2007
90 , book "Simon Marlow" "Parallel and Concurrent Programming in Haskell" 2013
91 , book "Richard Bird" "Introduction to Functional Programming using Haskell" 1998
92 ]
93```
95Now, given an optional search string `q`, we want to perform a case insensitive
96search in that list of books. We're obviously not going to try and implement
97the best possible algorithm, this is out of scope for this tutorial. The
98following simple linear scan will do, given how small our list is.
100``` haskell
101searchBook :: Monad m => Maybe Text -> m (Search Book)
102searchBook Nothing = return (mkSearch "" books)
103searchBook (Just q) = return (mkSearch q books')
105 where books' = filter (\b -> q' `T.isInfixOf` T.toLower (author b)
106 || q' `T.isInfixOf` T.toLower (title b)
107 )
108 books
109 q' = T.toLower q
110```
112We also need an endpoint that generates random points `(x, y)` with `-1 <= x,y
113<= 1`. The code below uses
114[random](http://hackage.haskell.org/package/random)'s `System.Random`.
116``` haskell
117randomPoint :: MonadIO m => m Point
118randomPoint = liftIO . getStdRandom $ \g ->
119 let (rx, g') = randomR (-1, 1) g
120 (ry, g'') = randomR (-1, 1) g'
121 in (Point rx ry, g'')
122```
124If we add static file serving, our server is now complete.
126``` haskell
127api :: Proxy API
128api = Proxy
130api' :: Proxy API'
131api' = Proxy
133server :: Server API
134server = randomPoint
135 :<|> searchBook
137server' :: Server API'
138server' = server
139 :<|> serveDirectory "static"
141app :: Application
142app = serve api' server'
144main :: IO ()
145main = run 8000 app
146```
148Why two different API types, proxies and servers though? Simply because we
149don't want to generate javascript functions for the `Raw` part of our API type,
150so we need a `Proxy` for our API type `API'` without its `Raw` endpoint.
152Very similarly to how one can derive haskell functions, we can derive the
153javascript with just a simple function call to `jsForAPI` from
154`Servant.JS`.
156``` haskell
157apiJS1 :: Text
158apiJS1 = jsForAPI api jquery
159```
161This `Text` contains 2 Javascript functions, 'getPoint' and 'getBooks':
163``` javascript
165var getPoint = function(onSuccess, onError)
167 $.ajax(
168 { url: '/point'
169 , success: onSuccess
170 , error: onError
171 , type: 'GET'
172 });
175var getBooks = function(q, onSuccess, onError)
177 $.ajax(
178 { url: '/books' + '?q=' + encodeURIComponent(q)
179 , success: onSuccess
180 , error: onError
181 , type: 'GET'
182 });
184```
186We created a directory `static` that contains two static files: `index.html`,
187which is the entrypoint to our little web application; and `ui.js`, which
188contains some hand-written javascript. This javascript code assumes the two
189generated functions `getPoint` and `getBooks` in scope. Therefore we need to
190write the generated javascript into a file:
192``` haskell
193writeJSFiles :: IO ()
194writeJSFiles = do
195 T.writeFile "static/api.js" apiJS1
196 jq <- T.readFile =<< Language.Javascript.JQuery.file
197 T.writeFile "static/jq.js" jq
198```
200(We're also writing the jquery library into a file, as it's also used by
201`ui.js`.) `static/api.js` will be included in `index.html` and the two
202generated functions will therefore be available in `ui.js`.
204And we're good to go. You can start the `main` function of this file and go to
205`http://localhost:8000/`. Start typing in the name of one of the authors in our
206database or part of a book title, and check out how long it takes to
207approximate pi using the method mentioned above.
209## Customizations
211Instead of calling `jquery`, you can call its variant `jqueryWith`.
212Here are the type definitions
214```haskell ignore
215jquery :: JavaScriptGenerator
216jqueryWith :: CommonGeneratorOptions -> JavaScriptGenerator
217```
219The `CommonGeneratorOptions` will let you define different behaviors to
220change how functions are generated. Here is the definition of currently
221available options:
223```haskell ignore
224data CommonGeneratorOptions = CommonGeneratorOptions
225 {
226 -- | function generating function names
227 functionNameBuilder :: FunctionName -> Text
228 -- | name used when a user want to send the request body (to let you redefine it)
229 , requestBody :: Text
230 -- | name of the callback parameter when the request was successful
231 , successCallback :: Text
232 -- | name of the callback parameter when the request reported an error
233 , errorCallback :: Text
234 -- | namespace on which we define the js function (empty mean local var)
235 , moduleName :: Text
236 -- | a prefix that should be prepended to the URL in the generated JS
237 , urlPrefix :: Text
238 }
239```
241This pattern is available with all supported backends, and default values are provided.
243## Vanilla support
245If you don't use JQuery for your application, you can reduce your
246dependencies to simply use the `XMLHttpRequest` object from the standard API.
248Use the same code as before but simply replace the previous `apiJS` with
249the following one:
251``` haskell
252apiJS2 :: Text
253apiJS2 = jsForAPI api vanillaJS
254```
256The rest is *completely* unchanged.
258The output file is a bit different, but it has the same parameters,
260``` javascript
263var getPoint = function(onSuccess, onError)
265 var xhr = new XMLHttpRequest();
266 xhr.open('GET', '/point', true);
267 xhr.setRequestHeader(\"Accept\",\"application/json\");
268 xhr.onreadystatechange = function (e) {
269 if (xhr.readyState == 4) {
270 if (xhr.status == 204 || xhr.status == 205) {
271 onSuccess();
272 } else if (xhr.status >= 200 && xhr.status < 300) {
273 var value = JSON.parse(xhr.responseText);
274 onSuccess(value);
275 } else {
276 var value = JSON.parse(xhr.responseText);
277 onError(value);
278 }
279 }
280 }
281 xhr.send(null);
284var getBooks = function(q, onSuccess, onError)
286 var xhr = new XMLHttpRequest();
287 xhr.open('GET', '/books' + '?q=' + encodeURIComponent(q), true);
288 xhr.setRequestHeader(\"Accept\",\"application/json\");
289 xhr.onreadystatechange = function (e) {
290 if (xhr.readyState == 4) {
291 if (xhr.status == 204 || xhr.status == 205) {
292 onSuccess();
293 } else if (xhr.status >= 200 && xhr.status < 300) {
294 var value = JSON.parse(xhr.responseText);
295 onSuccess(value);
296 } else {
297 var value = JSON.parse(xhr.responseText);
298 onError(value);
299 }
300 }
301 }
302 xhr.send(null);
306```
308And that's all, your web service can of course be accessible from those
309two clients at the same time!
311## Axios support
313### Simple usage
315If you use Axios library for your application, we support that too!
317Use the same code as before but simply replace the previous `apiJS` with
318the following one:
320``` haskell
321apiJS3 :: Text
322apiJS3 = jsForAPI api $ axios defAxiosOptions
323```
325The rest is *completely* unchanged.
327The output file is a bit different,
329``` javascript
332var getPoint = function()
334 return axios({ url: '/point'
335 , method: 'get'
336 });
341var getBooks = function(q)
343 return axios({ url: '/books' + '?q=' + encodeURIComponent(q)
344 , method: 'get'
345 });
348```
350**Caution:** In order to support the promise style of the API, there are no onSuccess
351nor onError callback functions.
353### Defining Axios configuration
355Axios lets you define a 'configuration' to determine the behavior of the
356program when the AJAX request is sent.
358We mapped this into a configuration
360``` haskell
361data AxiosOptions = AxiosOptions
362 { -- | indicates whether or not cross-site Access-Control requests
363 -- should be made using credentials
364 withCredentials :: !Bool
365 -- | the name of the cookie to use as a value for xsrf token
366 , xsrfCookieName :: !(Maybe Text)
367 -- | the name of the header to use as a value for xsrf token
368 , xsrfHeaderName :: !(Maybe Text)
369 }
370```
372## Angular support
374### Simple usage
376You can apply the same procedure as with `vanillaJS` and `jquery`, and
377generate top level functions.
379The difference is that `angular` Generator always takes an argument.
381``` haskell
382apiJS4 :: Text
383apiJS4 = jsForAPI api $ angular defAngularOptions
384```
386The generated code will be a bit different than previous generators. An extra
387argument `$http` will be added to let Angular magical Dependency Injector
388operate.
390**Caution:** In order to support the promise style of the API, there are no onSuccess
391nor onError callback functions.
393``` javascript
396var getPoint = function($http)
398 return $http(
399 { url: '/point'
400 , method: 'GET'
401 });
406var getBooks = function($http, q)
408 return $http(
409 { url: '/books' + '?q=' + encodeURIComponent(q)
410 , method: 'GET'
411 });
414```
416You can then build your controllers easily
418``` javascript
420app.controller("MyController", function($http) {
421 this.getPoint = getPoint($http)
422 .success(/* Do something */)
423 .error(/* Report error */);
425 this.getPoint = getBooks($http, q)
426 .success(/* Do something */)
427 .error(/* Report error */);
428});
429```
431### Service generator
433You can also generate automatically a service to wrap the whole API as
434a single Angular service:
436``` javascript
437app.service('MyService', function($http) {
438 return ({
439 postCounter: function()
440 {
441 return $http(
442 { url: '/counter'
443 , method: 'POST'
444 });
445 },
446 getCounter: function()
447 {
448 return $http(
449 { url: '/books' + '?q=' + encodeURIComponent(q), true);
450 , method: 'GET'
451 });
452 }
453 });
454});
455```
457To do so, you just have to use an alternate generator.
459``` haskell
460apiJS5 :: Text
461apiJS5 = jsForAPI api $ angularService defAngularOptions
462```
464Again, it is possible to customize some portions with the options.
466``` haskell
467data AngularOptions = AngularOptions
468 { -- | When generating code with wrapInService, name of the service to generate, default is 'app'
469 serviceName :: Text
470 , -- | beginning of the service definition
471 prologue :: Text -> Text -> Text
472 , -- | end of the service definition
473 epilogue :: Text
474 }
475```
477# Custom function name builder
479Servant comes with three name builders included:
481- camelCase (the default)
482- concatCase
483- snakeCase
485Keeping the JQuery as an example, let's see the impact:
487``` haskell
488apiJS6 :: Text
489apiJS6 = jsForAPI api $ jqueryWith defCommonGeneratorOptions { functionNameBuilder= snakeCase }
490```
492This `Text` contains 2 Javascript functions:
494``` javascript
497var get_point = function(onSuccess, onError)
499 $.ajax(
500 { url: '/point'
501 , success: onSuccess
502 , error: onError
503 , type: 'GET'
504 });
507var get_books = function(q, onSuccess, onError)
509 $.ajax(
510 { url: '/books' + '?q=' + encodeURIComponent(q)
511 , success: onSuccess
512 , error: onError
513 , type: 'GET'
514 });
517```