/
base.js
742 lines (624 loc) · 23.8 KB
/
base.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
var crypto = require('crypto'),
http = require('http'),
https = require('https'),
URL = require('url');
var async = require("async");
var Class = require("./inheritance").Class,
color = require("./color"),
error = require("./error"),
is_ans1_token = require("./utils").is_ans1_token,
StreamingUpload = require("./streaming").StreamingUpload,
urljoin = require("./utils").urljoin,
defaults = require("./utils").defaults,
io = require("./io");
var XMLHttpRequest = io.XMLHttpRequest;
// There are certain things such as console color capabilities that just
// can't be feature-detected, sadly. This is a pretty safe alternative.
color.enable(!io._native);
var Client = Class.extend({
// Base client version
VERSION: "1.0",
redacted_request: ['password'],
redacted_response: [
'private_key',
// TODO: This is a mega hack to redact the private key in certain situation
// problem is that properties are ordered non-deterministically so "data"
// could come before "private_key" we need a better way to do this. But it
// works for now.
'private_key": null, "data'
],
// Small hack, but if we're not using the native XHR, we're almost certainly
// in a NodeJS environment, not in a browser.
is_browser: io._native,
// Override `version_overrides` to force a rewrite of the service catalog URLs. For example,
// to rewrite "v2.0" to "v3" for the "identity" service, you'd specify:
// version_overrides: {
// identity: [["v2.0", "v3"]]
// }
//
version_overrides: {},
init: function (options) {
options = options || {};
options = defaults({}, options, Client.global_init_options);
this.user_agent = options.user_agent || "js-openclient";
this.debug = options.debug || false;
this.log_level = this.debug ? "debug" : (options.log_level || "warn");
this.truncate_long_response = options.truncate_long_response || true; // Set default truncation to truncate...
this.truncate_response_at = options.truncate_response_at || -1; // but only based on specific truncation lengths in params.
this.url = options.url;
this.scoped_token = options.scoped_token || null;
this.unscoped_token = options.unscoped_token || null;
this.service_catalog = options.service_catalog || [];
this.tenant = options.project || null;
this.user = options.user || null;
// Allow URL rewrite hacks to bypass proxy issues.
// The argument should be an array in the form of [<match>, <replacement>]
this.url_rewrite = options.url_rewrite || false;
this._log_level = this.log_levels[this.log_level]; // Store the numeric version so we don't recalculate it every time.
},
log_levels: {
"critical": 100,
"error": 80,
"warn": 60,
"info": 40,
"debug": 20
},
log_level_method_map: {
100: function (string) { console.error(color.bold(color.red(string))); },
80: function (string) { console.error(color.red(string)); },
60: function (string) { console.warn(color.yellow(string)); },
40: function (string) { console.info(color.cyan(string)); },
20: console.log
},
// Generic logging function that outputs to the appropriate log method with
// colorized output, etc.
log: function (level) {
if (typeof level !== "number") level = this.log_levels[level];
if (level >= this._log_level) {
this.log_level_method_map[level](Array.prototype.slice.apply(arguments, [1, arguments.length]).join(" "));
}
},
redact: function (json_string, redacted) {
for (var i = 0; i < redacted.length; i++) {
var re = new RegExp('("' + redacted[i] + '":\\s?)"(([^\\"]|\\\\|\\")*?)"', "g");
json_string = json_string.replace(re, '$1"*****"');
}
return json_string;
},
// Format headers to pretty-print for easier reading.
format_headers: function (headers) {
var formatted = "";
for (var header in headers) {
if (headers.hasOwnProperty(header)) {
formatted += "\n" + header + ": " + headers[header];
}
}
return formatted;
},
// Fetches a URL for the current service type with the given endpoint type.
url_for: function (endpoint_type, service_type) {
var search_service_type = service_type || this.service_type,
overrides = this.version_overrides[search_service_type];
for (var i = 0; i < this.service_catalog.length; i++) {
if (this.service_catalog[i].type === search_service_type) {
var url = this.service_catalog[i].endpoints[0][endpoint_type];
if (overrides) {
overrides.forEach(function(override) {
var from = override[0],
to = override[1];
url = url.replace(from, to);
});
}
return url;
}
}
if (service_type) { // If we came up empty for a specific search, return null.
return null;
} else { // Otherwise try returning a pre-set URL.
return this.url;
}
},
log_request: function (level, method, url, headers, data) {
var args = [level, "\nREQ:", method, url, this.format_headers(headers)];
if (data) args = args.concat(["\nbody:", this.redact(data, this.redacted_request)]);
this.log.apply(this, args);
},
log_response: function (level, method, url, status, headers, data) {
var args = [level, "\nRES:", method, url, "\nstatus:", status, this.format_headers(headers)];
if (data) args = args.concat(["\nbody:", this.redact(data, this.redacted_response)]);
this.log.apply(this, args);
},
process_response: function (method, url, data, status, response_text, req_headers, resp_headers, params, callback) {
var client = this;
// We may have missed logging the request that triggered the error
// if the log level was too low so we check and log here.
if ((status === 0 || status >= 400) && client._log_level < client.log_levels.error) {
client.log_request("error", method, url, req_headers, data);
}
if (typeof response_text === "string") {
// If not set, check for a param truncation but fallback to -1, otherwise respect the user-defined global truncation.
var truncate_at = client.truncate_response_at === -1 ? (params.truncate_at || client.truncate_response_at) : client.truncate_response_at;
if (
client.truncate_long_response &&
truncate_at >= 0 &&
response_text.length >= client.truncate_response_at
) {
response_text = response_text.substring(0, truncate_at) + "... (truncated)";
}
if (status === 0) {
response_text = "<REQUEST ABORTED>";
}
client.log_response((status === 0 || status >= 400) ? "error" : "info",
method, url, status, resp_headers, response_text);
}
// Response handling.
// Ignore informational codes for now (1xx).
// Handle successes (2xx).
if (status >= 200 && status < 300) {
var result;
if (params.raw_result) {
result = response_text;
} else if (response_text) {
if (typeof response_text === "string") {
try {
result = JSON.parse(response_text);
} catch (e) {
client.log("error", "Invalid JSON response");
}
} else {
result = response_text;
}
if (result) {
if (params.result_key) {
result = result[params.result_key];
}
if (params.parseResult) {
result = params.parseResult(result);
}
}
} else {
if (params.parseHeaders) {
result = params.parseHeaders(resp_headers);
}
}
callback(null, result);
}
// Redirects are handled transparently by XMLHttpRequest.
// Handle errors (4xx, 5xx)
if (status === 0 || status >= 400) {
var api_error,
message,
Err = error.get_error(status),
err;
try {
api_error = JSON.parse(response_text);
if (api_error.hasOwnProperty('error')) api_error = api_error.error;
// Fix for the way OpenStack services wrap their error objects in arbitrary keys.
for (var key in api_error) {
if (api_error.hasOwnProperty(key) && api_error[key].message && api_error[key].code) {
api_error = api_error[key];
}
}
message = api_error.message;
}
catch (problem) {
message = response_text;
}
err = new Err(status, message, api_error);
callback(err);
}
},
// Core method for making requests to API endpoints.
// All other methods eventually route back to this one.
request: function (params, callback) {
var xhr = new XMLHttpRequest(),
client = this,
token = this.scoped_token || this.unscoped_token,
url = params.url,
dataType,
data,
headers,
method;
// Short circuit if response data is already provided (such as from a push notification)
if (params.push_data) {
var response_data = {};
if (params.result_key) response_data[params.result_key] = params.push_data;
else response_data = params.push_data;
return client.process_response('AMQP', '', '', 200, response_data, '', '', params, end);
}
// This is mainly necessary due to Glance needing the Content-Length
// header set on PUT requests, but xmlhttprequest only setting it for POST.
if (params.allow_headers && typeof xhr.setDisableHeaderCheck === "function") xhr.setDisableHeaderCheck(true);
// When run in Node we can optionally allow using insecure/self-signed certs.
if (params.allow_insecure_cert && typeof xhr.setAllowInsecureCert === "function") xhr.setAllowInsecureCert(true);
if (params.query) {
var query_params = [];
Object.keys(params.query).forEach(function (key) {
query_params.push(key + "=" + params.query[key]);
});
url += "?" + query_params.join("&");
}
if (this.url_rewrite) {
url = url.replace(this.url_rewrite[0], this.url_rewrite[1]);
}
xhr.open(params.method, url, true);
method = params.method.toUpperCase();
headers = params.headers || {};
if (!headers["Content-Type"]) {
if (params.use_http_form_data) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else {
headers["Content-Type"] = "application/json";
}
}
headers.Accept = "application/json";
headers['X-Requested-With'] = this.user_agent;
// Set our auth token if we have one.
if (token) {
headers['X-Auth-Token'] = token.id;
}
// Create our XMLHttpRequest and set headers.
for (var header in headers) {
if (headers.hasOwnProperty(header)) {
xhr.setRequestHeader(header, headers[header]);
}
}
function end(err, result) {
if (!err && params.defer) {
return params.defer(result, function (e, r) {
params.defer = null;
result = r || result;
end(e, result);
});
}
if (err && params.error) {
params.error(err, xhr);
}
if (!err && params.success) {
params.success(result, xhr);
}
if (callback) {
callback(err, result, xhr);
}
}
// Set up our state change handlers.
xhr.onreadystatechange = function () {
var status = parseInt(xhr.status, 10);
if (xhr.readyState === 4) {
var raw_headers = xhr.getAllResponseHeaders(),
lines = raw_headers.split(/\r\n|\r|\n|;/),
resp_headers = {};
lines.forEach(function (line) {
var matches;
line = line.trim();
// Standard header format.
matches = line.match(/^(.*)?: (.*)$/);
if (matches) {
resp_headers[matches[1].toLowerCase()] = matches[2];
} else {
// Sometimes headers come back from XHR with an = sign instead of a colon...
matches = line.match(/^(.*)?=(.*)$/);
if (matches) {
resp_headers[matches[1].toLowerCase()] = matches[2];
}
}
});
client.process_response(method, url, data, status, xhr.responseText, headers, resp_headers, params, end);
}
};
dataType = typeof params.data;
// Finally, send out the request.
if (dataType === 'string' || dataType === 'number') {
data = params.data;
client.log_request("info", method, url, headers, data);
xhr.send(params.data);
} else if (dataType === 'object' && Object.keys(params.data).length > 0) {
// Data is guaranteed to be an object by this point.
if (params.use_http_form_data) {
data = URL.format({query: params.data}).substr(1);
} else {
data = JSON.stringify(params.data);
}
client.log_request("info", method, url, headers, data);
xhr.send(data);
} else {
client.log_request("info", method, url, headers);
xhr.send();
}
// Otherwise return null so the manager class can return itself for chaining.
return;
},
get: function (params, callback) {
params.method = "GET";
return this.request(params, callback);
},
post: function (params, callback) {
params.method = "POST";
return this.request(params, callback);
},
head: function (params, callback) {
params.method = "HEAD";
return this.request(params, callback);
},
put: function (params, callback) {
params.method = "PUT";
return this.request(params, callback);
},
patch: function (params, callback) {
params.method = "PATCH";
return this.request(params, callback);
},
del: function (params, callback) {
params.method = "DELETE";
return this.request(params, callback);
},
// Authentication against the auth URL
authenticate: function (params, callback) {
var credentials = {},
client = this;
function authenticated(result, xhr) {
if (result.token) {
if (is_ans1_token(result.token.id)) {
// Rewrite the token id as the MD5 hash since we can use that in place
// of the full PKI-signed token (which is enormous).
result.token.id = crypto.createHash('md5').update(result.token.id).digest("hex");
}
if (result.token.tenant) {
client.scoped_token = result.token;
client.service_catalog = result.serviceCatalog;
client.tenant = result.token.tenant;
client.user = result.user;
}
else {
client.unscoped_token = result.token;
}
}
if (callback) callback(null, result, xhr);
if (params.success) params.success(client, xhr);
}
params = params || {};
if (params.username && params.password) {
credentials.auth = {
"passwordCredentials" : {
"username": params.username,
"password": params.password
}
};
}
else if (params.token) {
credentials.auth = {"token": {"id": params.token}};
}
if (params.project) {
credentials.auth.tenantName = params.project;
}
this.post({
url: urljoin(this.url, "/tokens"),
data: credentials,
allow_insecure_cert: params.allow_insecure_cert,
result_key: "access",
success: authenticated,
error: function (err, xhr) {
if (callback) callback(err);
if (params.error) params.error(err);
}
});
return this;
}
});
var Manager = Class.extend({
// Default endpoint type for API calls to talk to.
endpoint_type: "internalURL",
endpoint_type_backup: "publicURL",
urljoin: urljoin, // For convenience.
init: function (client) {
this.client = client;
this.plural = this.plural || this.namespace;
this.singular = this.singular || this.get_singular();
// Mapping of manager CRUD types to client HTTP methods.
// APIs that don't follow the common pattern can override this to
// fit their API scheme.
// This should be initialized for each manager to avoid conflicts.
this.method_map = {
create: "post",
update: "put",
get: "get",
del: "del"
};
},
_rpc_to_api: function (rpc) { return rpc; }, // No-op by default
// Convenience function that attempts to take english plural forms and
// make them singular by removing the "s". This function exists so that
// most plural and singular resource names can be derived from that single
// "namespace" value on the manager class.
get_singular: function () {
if (this.plural.substr(-1) !== "s") {
throw new Error("Could not automatically determine singular resource name.");
}
return this.plural.substr(0, this.plural.length - 1);
},
// Fetches the appropriate service endpoint from the service catalog.
get_base_url: function (params) {
var base = this.client.url_for(params.endpoint_type || this.endpoint_type);
if (!base) {
base = this.client.url_for(params.endpoint_type_backup || this.endpoint_type_backup);
}
return urljoin(base, this.prepare_namespace(params));
},
// Most of the APIs want data sent to them to be wrapped with the singular
// form of the resource's name; this method allows customization of that
// if necessary.
prepare_data: function (data) {
if (this.use_raw_data) return data;
var wrapped_data = {};
wrapped_data[this.singular] = data;
return wrapped_data;
},
// Placeholder function which can be customized by subclasses for APIs
// with complex or illogical API namespacing.
prepare_namespace: function (params) {
return this.namespace;
},
// Prepares common parameters to be passed to client.request().
prepare_params: function (params, url, plural_or_singular) {
params = params || {};
params.url = params.url || url;
// Allow false-y values for the result key.
if (typeof(params.result_key) === "undefined") {
params.result_key = params.result_key || this[plural_or_singular];
}
if (params.push_data) {
params.push_data = this._rpc_to_api(params.push_data);
}
// Ensure that we only wrap the data object if data is present and
// contains actual values.
if (params.use_raw_data) {
params.data = params.data;
} else if (params.data && typeof params.data === "object" && Object.keys(params.data).length > 0) {
params.data = this.prepare_data(params.data || {});
} else {
params.data = {};
}
return params;
},
normalize_id: function (params) {
if (params.data && params.data.id) {
params.id = params.data.id;
}
return params;
},
// READ OPERATIONS
// Fetches a list of all objects available to the authorized user.
// Default: GET to /<namespace>
all: function (params, callback) {
params.manager_method = "all";
params = this.prepare_params(params, this.get_base_url(params), "plural");
return this.client[params.http_method || this.method_map.get](params, callback);
},
// Fetches a single object based on the parameters passed in.
// Default: GET to /<namespace>/<id>
get: function (params, callback) {
params.manager_method = "get";
params = this.normalize_id(params);
var url = urljoin(this.get_base_url(params), params.id);
params = this.prepare_params(params, url, "singular");
return this.client[params.http_method || this.method_map.get](params, callback);
},
// Fetches a list of objects based on the filter criteria passed in.
// Default: GET to /<namespace>?<query params>
filter: function (params, callback) {
throw new error.NotImplemented();
},
// Fetches a list of objects based on the filter criteria passed in.
// Default *SHOULD BE BUT ISN'T*: GET to /<namespace>?<list of ids>
// In reality the default is to mock this method with parallel get calls.
in_bulk: function (params, callback) {
var manager = this,
lookups = [],
success = params.success,
error = params.error;
if (params.success) delete params.success;
if (params.error) delete params.error;
params.data.ids.forEach(function (id) {
lookups.push(function (done) {
var cloned_params = JSON.parse(JSON.stringify(params));
delete cloned_params.data.ids;
cloned_params.data.id = id;
cloned_params.success = function (result, xhr) { return done(null, result); };
cloned_params.error = function (err, xhr) { return done(err); };
manager.get(cloned_params);
});
});
async.parallel(lookups, function (err, results) {
if (err) return manager.safe_complete(err, null, null, {error: error}, callback);
manager.safe_complete(null, results, {status: 200}, {success: success}, callback);
});
},
// WRITE OPERATIONS
// Creates a new object.
// Default: POST to /<namespace>
create: function (params, callback) {
params.manager_method = "create";
params = this.prepare_params(params, this.get_base_url(params), "singular");
return this.client[params.http_method || this.method_map.create](params, callback);
},
// Updates an existing object.
// Default: POST to /<namespace>/<id>
update: function (params, callback) {
params.manager_method = "update";
params = this.normalize_id(params);
var url = urljoin(this.get_base_url(params), params.id);
params = this.prepare_params(params, url, "singular");
return this.client[params.http_method || this.method_map.update](params, callback);
},
// DELETE OPERATIONS
// Deletes an object.
// Default: DELETE to /<namespace>/<id>
del: function (params, callback) {
params.manager_method = "del";
params = this.normalize_id(params);
var url = urljoin(this.get_base_url(params), params.id);
params = this.prepare_params(params, url, "singular");
return this.client[params.http_method || this.method_map.del](params, callback);
},
// Utility method for applying all the necessary combinations of callbacks
// with the right combination of variables.
safe_complete: function (err, result, xhr, params, callback) {
if (err) {
if (!xhr) xhr = {status: 500};
if (params.error) params.error(err, xhr);
} else {
if (!xhr) xhr = {status: 200};
if (params.success) params.success(result, xhr);
}
if (callback) callback(err, result, xhr);
},
// Method to initiate binary file transfers since the XMLHttpRequest
// library currently tries to transfer everything as utf8, and browsers
// don't support streaming transfers via XHR.
_openBinaryStream: function (params, headers, token, callback) {
var client = this.client;
var matches = params.url.match(/^(https?)\:\/\/([^\/?#]+)(?:[\/?#]|$)/i),
host_and_port = matches[2].split(':'),
request_module, request_default_port;
if (matches[1] === 'https') {
request_module = https;
request_default_port = 443;
}
else {
request_module = http;
request_default_port = 80;
}
var options = {
hostname: host_and_port[0],
port: host_and_port[1] || request_default_port,
path: '/' + params.url.substring(matches[0].length),
method: params.method,
headers: {
'X-Auth-Token': token
}
};
// Create our XMLHttpRequest and set headers.
for (var header in headers) {
if (headers.hasOwnProperty(header)) {
options.headers[header] = headers[header];
}
}
// For our initial connection, simply indicate that the socket is ready.
var request = request_module.request(options).on('socket', function (socket) {
callback();
});
client.log('info', '\nREQ:', params.method, params.url);
Object.keys(options.headers).forEach(function (key) {
client.log('info', key + ":", options.headers[key]);
});
// Return a StreamingUpload object so we can continue writing to it.
return new StreamingUpload(this.client, request, params);
}
});
// These are set on the constructor rather than the prototype so they're unique to this "class"
Client.global_init_options = {
debug: false,
log_level: "warn"
};
exports.Client = Client;
exports.Manager = Manager;