1 // Data service backed by Arvados.
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'
8 // // This part needs a better API:
9 // nodelist = m.prop();
10 // c.ready.then(function() { c.Node.list().then(nodelist) });
13 // nodelist = m.deferred();
14 // nodelist = c.api('nodes.list', {filters: []}, nodelist);
15 // nodelist(); // undefined
16 // nodelist.then(function() { nodelist(); }); // [{uuid:...},...]
18 module.exports = ArvadosConnection;
20 var m = require('mithril');
22 ArvadosConnection.connections = {};
23 ArvadosConnection.make = Make;
25 function Make(connectionId, apiPrefix) {
26 var conns = ArvadosConnection.connections;
27 apiPrefix = apiPrefix || connectionId;
28 if (!conns[connectionId]) {
29 conns[connectionId] = new ArvadosConnection(apiPrefix);
31 return conns[connectionId];
34 function ArvadosConnection(apiPrefix) {
35 var connection = this;
37 connection.apiPrefix = m.prop(apiPrefix);
38 connection.discoveryDoc = dd;
39 connection.state = m.prop('loading');
41 connection.find = find;
42 connection.loginLink = loginLink;
43 connection.token = token;
44 connection.webSocket = m.prop({});
48 connection.ready = m.request({
51 url: 'https://' + apiPrefix + '.arvadosapi.com/discovery/v1/apis/arvados/v1/rest'
54 then(connection.discoveryDoc).
55 then(setupModelClasses).
58 function(err){connection.state('error: '+err); m.redraw();});
62 // URL that will initiate the login process, pass the new token to
63 // localStorage via login-callback, then return to the current
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())));
72 // getter-setter, backed by localStorage. Currently supports only
73 // one connection per apiPrefix.
74 function token(newToken) {
77 tokens = JSON.parse(window.localStorage.tokens);
81 if (arguments.length === 0) {
82 return tokens[apiPrefix];
84 tokens[apiPrefix] = newToken;
85 window.localStorage.tokens = JSON.stringify(tokens);
90 // Wait for discovery doc if necessary, then perform API call and
91 // resolve the returned promise.
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);
102 // Private instance variables
105 var uuidInfixClassName = {};
109 function ModelClass(resourceName) {
110 var resourceClass = function() {
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]);
122 postdata[key] = params[key];
124 path = method.path.replace(/{(.*?)}/, function(_, key) {
125 var val = postdata[key];
126 delete postdata[key];
127 return encodeURIComponent(val);
129 path = dd().rootUrl + dd().servicePath + path;
131 method: method.httpMethod,
137 resourceClass.find = function(uuid, refreshFlag) {
138 if (!refreshFlag && store[uuid]) return store[uuid];
139 else return api(resourceName, 'get', {uuid:uuid});
141 return resourceClass;
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];
151 throw new Error("No class for "+className+" for infix "+infix);
153 theClass.find(uuid, refreshFlag);
155 store[uuid] = store[uuid] || m.prop();
159 function request(args) {
160 args.config = function(xhr) {
161 xhr.setRequestHeader('Authorization', 'OAuth2 '+connection.token());
163 return m.request(args);
166 // Update local cache with data just received in API response.
167 function updateStore(response) {
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];
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];
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;
199 var resources = connection.discoveryDoc().resources;
200 Object.keys(resources).map(function(ctrl) {
202 var methods = resources[ctrl].methods;
204 modelClassName = resources[ctrl].methods.get.response.$ref;
206 console.log("Hm, could not handle resource '"+ctrl+"'");
209 if (!connection[modelClassName]) {
210 console.log("Hm, no schema for response type '"+ctrl+"'");
213 Object.keys(methods).map(function(action) {
214 connection[modelClassName].addAction(action, methods[action]);
217 connection.state('ready');
220 function setupWebSocket() {
222 if (!connection.token()) {
223 // No sense trying to connect without a valid token.
224 return connection.webSocket({});
227 dd().websocketUrl + '?api_token=' + connection.token());
228 ws.startedAt = new Date();
229 ws.sendJson = function(object) {
230 ws.send(JSON.stringify(object));
232 ws.onopen = function(event) {
233 // TODO: subscribe to logs about uuids in
234 // connection.store, not everything.
235 ws.sendJson({method:'subscribe'});
237 ws.onreadystatechange = m.redraw.bind(m, false);
238 ws.onmessage = function(event) {
239 var message = JSON.parse(event.data);
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;
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];
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);
260 objectProp()._cacheTime = new Date();
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
274 setupWebSocket.backoff = Math.min(
275 (setupWebSocket.backoff || 30), 30) * 2 + 1;
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;
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);
289 return connection.webSocket(ws);