4831: Rearrange some files.
[arvados.git] / apps / backstage / arvados / client.js
1 // Data service backed by Arvados.
2 //
3 // c = new ArvadosConnection('xyzzy'); // connect to xyzzy.arvadosapi.com
4 // c.token('asdfasdf'); // set token
5 // c.state(); // 'loading'
6 // c.ready.then(function() { c.state() }); // 'ready'
7 //
8 // // This part needs a better API:
9 // nodelist = m.prop();
10 // c.ready.then(function() { c.Node.list().then(nodelist) });
11 //
12 // // Better?
13 // nodelist = m.deferred();
14 // nodelist = c.api('nodes.list', {filters: []}, nodelist);
15 // nodelist(); // undefined
16 // nodelist.then(function() { nodelist(); }); // [{uuid:...},...]
17
18 module.exports = ArvadosConnection;
19
20 var m = require('mithril');
21
22 ArvadosConnection.connections = {};
23 ArvadosConnection.make = Make;
24
25 function Make(connectionId, apiPrefix) {
26     var conns = ArvadosConnection.connections;
27     apiPrefix = apiPrefix || connectionId;
28     if (!conns[connectionId]) {
29         conns[connectionId] = new ArvadosConnection(apiPrefix);
30     }
31     return conns[connectionId];
32 }
33
34 function ArvadosConnection(apiPrefix) {
35     var connection = this;
36     var dd = m.prop();
37     connection.apiPrefix = m.prop(apiPrefix);
38     connection.discoveryDoc = dd;
39     connection.state = m.prop('loading');
40     connection.api = api;
41     connection.find = find;
42     connection.loginLink = loginLink;
43     connection.token = token;
44     connection.webSocket = m.prop({});
45
46     // Initialize
47
48     connection.ready = m.request({
49         background: true,
50         method: 'GET',
51         url: 'https://' + apiPrefix + '.arvadosapi.com/discovery/v1/apis/arvados/v1/rest'
52     });
53     connection.ready.
54         then(connection.discoveryDoc).
55         then(setupModelClasses).
56         then(setupWebSocket).
57         then(m.redraw,
58              function(err){connection.state('error: '+err); m.redraw();});
59
60     // Public methods
61
62     // URL that will initiate the login process, pass the new token to
63     // localStorage via login-callback, then return to the current
64     // route.
65     function loginLink() {
66         if (!dd()) return null;
67         return dd().rootUrl + 'login?return_to=' + encodeURIComponent(
68             (location.href.replace(/\?.*/,'') + '?/login-callback?apiPrefix=' + apiPrefix +
69              '&return_to=' + encodeURIComponent(m.route())));
70     }
71
72     // getter-setter, backed by localStorage. Currently supports only
73     // one connection per apiPrefix.
74     function token(newToken) {
75         var tokens;
76         try {
77             tokens = JSON.parse(window.localStorage.tokens);
78         } catch(e) {
79             tokens = {};
80         }
81         if (arguments.length === 0) {
82             return tokens[apiPrefix];
83         } else {
84             tokens[apiPrefix] = newToken;
85             window.localStorage.tokens = JSON.stringify(tokens);
86             return newToken;
87         }
88     }
89
90     // Wait for discovery doc if necessary, then perform API call and
91     // resolve the returned promise.
92     //
93     // modelClass: 'Collection', 'Node', etc.
94     // action: 'get', 'list', 'update', etc.
95     // params: {uuid:'foo',filters:[],...}
96     function api(modelClass, action, params) {
97         return connection.ready.then(function() {
98             return connection[modelClass][action](params);
99         }).then(updateStore);
100     }
101
102     // Private instance variables
103
104     var store = {};
105     var uuidInfixClassName = {};
106
107     // Private methods
108
109     function ModelClass(resourceName) {
110         var resourceClass = function() {
111             var model = this;
112         };
113         resourceClass.resourceName = resourceName;
114         resourceClass.addAction = function(action, method) {
115             resourceClass[action] = function(params) {
116                 var path, postdata = {};
117                 params = params || {};
118                 Object.keys(params).map(function(key) {
119                     if (params[key] instanceof Object)
120                         postdata[key] = JSON.stringify(params[key]);
121                     else
122                         postdata[key] = params[key];
123                 });
124                 path = method.path.replace(/{(.*?)}/, function(_, key) {
125                     var val = postdata[key];
126                     delete postdata[key];
127                     return encodeURIComponent(val);
128                 });
129                 path = dd().rootUrl + dd().servicePath + path;
130                 return request({
131                     method: method.httpMethod,
132                     url: path,
133                     data: postdata,
134                 });
135             };
136         };
137         resourceClass.find = function(uuid, refreshFlag) {
138             if (!refreshFlag && store[uuid]) return store[uuid];
139             else return api(resourceName, 'get', {uuid:uuid});
140         };
141         return resourceClass;
142     }
143
144     function find(uuid, refreshFlag) {
145         refreshFlag = refreshFlag || !store[uuid];
146         connection.ready.then(function() {
147             var infix = uuid.slice(6,11);
148             var className = uuidInfixClassName[infix];
149             var theClass = connection[className];
150             if (!theClass) {
151                 throw new Error("No class for "+className+" for infix "+infix);
152             }
153             theClass.find(uuid, refreshFlag);
154         });
155         store[uuid] = store[uuid] || m.prop();
156         return store[uuid];
157     }
158
159     function request(args) {
160         args.config = function(xhr) {
161             xhr.setRequestHeader('Authorization', 'OAuth2 '+connection.token());
162         };
163         return m.request(args);
164     }
165
166     // Update local cache with data just received in API response.
167     function updateStore(response) {
168         var items;
169         if (response.items) {
170             // Return an array of getters, with extra properties
171             // (items_available, etc.) tacked on to the array.
172             items = response.items.map(updateStore);
173             Object.keys(response).map(function(key) {
174                 if (key !== 'items') {
175                     items[key] = response[key];
176                 }
177             });
178             return items;
179         } else if (response.uuid) {
180             store[response.uuid] = store[response.uuid] || m.prop();
181             store[response.uuid](response);
182             store[response.uuid]()._cacheTime = new Date();
183             store[response.uuid]()._conn = connection;
184             return store[response.uuid];
185         } else {
186             return response;
187         }
188     }
189
190     function setupModelClasses(x) {
191         var schemas = connection.discoveryDoc().schemas;
192         Object.keys(schemas).map(function(modelClassName) {
193             if (modelClassName.search(/List$/) > -1) return;
194             var modelClass = new ModelClass(modelClassName);
195             modelClass.schema = schemas[modelClassName];
196             connection[modelClassName] = modelClass;
197             uuidInfixClassName[schemas[modelClassName].uuidPrefix] = modelClassName;
198         });
199         var resources = connection.discoveryDoc().resources;
200         Object.keys(resources).map(function(ctrl) {
201             var modelClassName;
202             var methods = resources[ctrl].methods;
203             try {
204                 modelClassName = resources[ctrl].methods.get.response.$ref;
205             } catch(e) {
206                 console.log("Hm, could not handle resource '"+ctrl+"'");
207                 return;
208             }
209             if (!connection[modelClassName]) {
210                 console.log("Hm, no schema for response type '"+ctrl+"'");
211                 return;
212             }
213             Object.keys(methods).map(function(action) {
214                 connection[modelClassName].addAction(action, methods[action]);
215             });
216         });
217         connection.state('ready');
218     }
219
220     function setupWebSocket() {
221         var ws;
222         if (!connection.token()) {
223             // No sense trying to connect without a valid token.
224             return connection.webSocket({});
225         }
226         ws = new WebSocket(
227             dd().websocketUrl + '?api_token=' + connection.token());
228         ws.startedAt = new Date();
229         ws.sendJson = function(object) {
230             ws.send(JSON.stringify(object));
231         };
232         ws.onopen = function(event) {
233             // TODO: subscribe to logs about uuids in
234             // connection.store, not everything.
235             ws.sendJson({method:'subscribe'});
236         };
237         ws.onreadystatechange = m.redraw.bind(m, false);
238         ws.onmessage = function(event) {
239             var message = JSON.parse(event.data);
240             var newAttrs;
241             var objectProp;
242             if (typeof message.object_uuid === 'string' &&
243                 message.event_type === 'update' &&
244                 message.object_uuid.slice(0,5) === apiPrefix &&
245                 (objectProp = store[message.object_uuid])) {
246                 newAttrs = message.properties.new_attributes;
247                 if (objectProp()) {
248                     // A local copy exists. Update whatever attributes
249                     // we see in the message.
250                     Object.keys(newAttrs).map(function(key) {
251                         objectProp()[key] = newAttrs[key];
252                     });
253                 } else {
254                     // We have a getter-setter ready for this object,
255                     // but it has no content yet. TODO: make the
256                     // server send a full API response, not just the
257                     // database columns, with these update messages.
258                     objectProp(newAttrs);
259                 }
260                 objectProp()._cacheTime = new Date();
261                 m.redraw();
262             }
263         };
264         ws.onclose = function(event) {
265             if (new Date() - ws.startedAt < 60000) {
266                 // If the last connection lasted less than 60 seconds,
267                 // there's probably something wrong -- it's not just
268                 // the expected occasional server reset or network
269                 // interruption -- so we should make sure to use a
270                 // pessimistic retry delay of at least 60 seconds, and
271                 // use ever-increasing delays until the connection
272                 // starts staying alive for more than a minute at a
273                 // time.
274                 setupWebSocket.backoff = Math.min(
275                     (setupWebSocket.backoff || 30), 30) * 2 + 1;
276             }
277             else {
278                 // The last connection lasted more than a
279                 // minute. Let's assume this is just a brief
280                 // interruption and things are going well most of the
281                 // time: delay 5 seconds, then try again.
282                 setupWebSocket.backoff = 5;
283             }
284             console.log("Websocket closed at " + new Date() +
285                         " with code=" + event.code +
286                         ", retry in "+setupWebSocket.backoff+"s");
287             window.setTimeout(setupWebSocket, setupWebSocket.backoff*1000);
288         };
289         return connection.webSocket(ws);
290     }
291 }