openclient
Version:
An opinionated client for RESTful APIs (particularly OpenStack's).
743 lines (624 loc) • 24.4 kB
JavaScript
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;