/*globals define, WebGMEGlobal*/
/*eslint-env node, browser*/
/**
* Client module for accessing the blob.
*
* @author lattmann / https://github.com/lattmann
* @author ksmyth / https://github.com/ksmyth
*/
define([
'blob/Artifact',
'blob/BlobMetadata',
'superagent',
'q',
'common/util/uint'
], function (Artifact, BlobMetadata, superagent, Q, UINT) {
'use strict';
/**
* Client to interact with the blob-storage. <br>
*
* @param {object} parameters
* @param {object} parameters.logger
* @constructor
* @alias BlobClient
*/
var BlobClient = function (parameters) {
var self = this;
// Store these to be able to create a new instance from an instance.
this.parameters = parameters;
this.artifacts = [];
if (parameters && parameters.logger) {
this.logger = parameters.logger;
} else {
/*eslint-disable no-console*/
var doLog = function () {
console.log.apply(console, arguments);
};
this.logger = {
debug: doLog,
log: doLog,
info: doLog,
warn: doLog,
error: doLog
};
console.warn('Since v1.3.0 BlobClient requires a logger, falling back on console.log.');
/*eslint-enable no-console*/
}
if (parameters && parameters.uploadProgressHandler) {
this.uploadProgressHandler = parameters.uploadProgressHandler;
} else {
this.uploadProgressHandler = function (fName, e) {
self.logger.debug('File upload of', fName, e.percent, '%');
};
}
this.logger.debug('ctor', {metadata: parameters});
if (parameters) {
this.server = parameters.server || this.server;
this.serverPort = parameters.serverPort || this.serverPort;
this.httpsecure = (parameters.httpsecure !== undefined) ? parameters.httpsecure : this.httpsecure;
this.apiToken = parameters.apiToken;
this.webgmeToken = parameters.webgmeToken;
this.keepaliveAgentOptions = parameters.keepaliveAgentOptions || {/* use defaults */};
} else {
this.keepaliveAgentOptions = {/* use defaults */};
}
this.origin = '';
if (this.httpsecure !== undefined && this.server && this.serverPort) {
this.origin = (this.httpsecure ? 'https://' : 'http://') + this.server + ':' + this.serverPort;
}
if (parameters && typeof parameters.relativeUrl === 'string') {
this.relativeUrl = parameters.relativeUrl;
} else if (typeof WebGMEGlobal !== 'undefined' && WebGMEGlobal.gmeConfig &&
typeof WebGMEGlobal.gmeConfig.client.mountedPath === 'string') {
this.relativeUrl = WebGMEGlobal.gmeConfig.client.mountedPath + '/rest/blob/';
} else {
this.relativeUrl = '/rest/blob/';
}
this.blobUrl = this.origin + this.relativeUrl;
this.isNodeOrNodeWebKit = typeof process !== 'undefined';
if (this.isNodeOrNodeWebKit) {
// node or node-webkit
this.logger.debug('Running under node or node-web-kit');
if (this.httpsecure) {
this.Agent = require('agentkeepalive').HttpsAgent;
} else {
this.Agent = require('agentkeepalive');
}
if (Object.hasOwn(this.keepaliveAgentOptions, 'ca') === false) {
this.keepaliveAgentOptions.ca = require('https').globalAgent.options.ca;
}
this.keepaliveAgent = new this.Agent(this.keepaliveAgentOptions);
}
this.logger.debug('origin', this.origin);
this.logger.debug('blobUrl', this.blobUrl);
};
/**
* Creates and returns a new instance of a BlobClient with the same settings as the current one.
* This can be used to avoid issues with the artifacts being book-kept at the instance.
* @returns {BlobClient} A new instance of a BlobClient
*/
BlobClient.prototype.getNewInstance = function () {
return new BlobClient(this.parameters);
};
BlobClient.prototype.getMetadataURL = function (hash) {
return this.origin + this.getRelativeMetadataURL(hash);
};
BlobClient.prototype.getRelativeMetadataURL = function (hash) {
var metadataBase = this.relativeUrl + 'metadata';
if (hash) {
return metadataBase + '/' + hash;
} else {
return metadataBase;
}
};
BlobClient.prototype._getURL = function (base, hash, subpath) {
var subpathURL = '';
if (subpath) {
subpathURL = subpath;
}
return this.relativeUrl + base + '/' + hash + '/' + encodeURIComponent(subpathURL);
};
BlobClient.prototype.getViewURL = function (hash, subpath) {
return this.origin + this.getRelativeViewURL(hash, subpath);
};
BlobClient.prototype.getRelativeViewURL = function (hash, subpath) {
return this._getURL('view', hash, subpath);
};
/**
* Returns the get-url for downloading a blob.
* @param {string} metadataHash
* @param {string} [subpath] - optional file-like path to sub-object if complex blob
* @return {string} get-url for blob
*/
BlobClient.prototype.getDownloadURL = function (metadataHash, subpath) {
return this.origin + this.getRelativeDownloadURL(metadataHash, subpath);
};
BlobClient.prototype.getRelativeDownloadURL = function (hash, subpath) {
return this._getURL('download', hash, subpath);
};
BlobClient.prototype.getCreateURL = function (filename, isMetadata) {
return this.origin + this.getRelativeCreateURL(filename, isMetadata);
};
BlobClient.prototype.getRelativeCreateURL = function (filename, isMetadata) {
if (isMetadata) {
return this.relativeUrl + 'createMetadata/';
} else {
return this.relativeUrl + 'createFile/' + encodeURIComponent(filename);
}
};
/**
* Adds a file to the blob storage.
* @param {string} name - file name.
* @param {string|Buffer|ArrayBuffer|stream.Readable} data - file content.
* !ReadStream currently only available from a nodejs setting
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with {string} <b>metadataHash</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.putFile = function (name, data, callback) {
var deferred = Q.defer(),
self = this,
contentLength,
req,
stream = null;
this.logger.debug('putFile', name);
function toArrayBuffer(buffer) {
var ab = new ArrayBuffer(buffer.length),
view = new Uint8Array(ab);
for (var i = 0; i < buffer.length; ++i) {
view[i] = buffer[i];
}
return ab;
}
if (typeof window === 'undefined') {
stream = require('stream');
}
// On node-webkit, we use XMLHttpRequest, but xhr.send thinks a Buffer is a string and encodes it in utf-8 -
// send an ArrayBuffer instead.
if (typeof window !== 'undefined' && typeof Buffer !== 'undefined' && data instanceof Buffer) {
data = toArrayBuffer(data); // FIXME will this have performance problems
}
// on node, empty Buffers will cause a crash in superagent
if (typeof window === 'undefined' && typeof Buffer !== 'undefined' && data instanceof Buffer) {
if (data.length === 0) {
data = '';
}
}
contentLength = Object.hasOwn(data, 'length') ? data.length : data.byteLength;
req = superagent.post(this.getCreateURL(name));
if (typeof window === 'undefined') {
req.agent(this.keepaliveAgent);
}
this._setAuthHeaders(req);
if (typeof data !== 'string' &&
!(data instanceof String) &&
typeof window === 'undefined' &&
!(data instanceof stream.Readable)) {
req.set('Content-Length', contentLength);
}
req.set('Content-Type', 'application/octet-stream');
if (typeof window === 'undefined' && data instanceof stream.Readable) {
const DEFAULT_ERROR = new Error('Failed to send stream data completely');
const errorHandler = err => deferred.reject(err || DEFAULT_ERROR);
data.on('error', errorHandler);
req.on('error', errorHandler);
req.on('response', function (res) {
var response = res.body;
// Get the first one
var hash = Object.keys(response)[0];
self.logger.debug('putFile - result', hash);
deferred.resolve(hash);
});
data.pipe(req);
} else {
req.send(data)
.on('progress', function (event) {
self.uploadProgressHandler(name, event);
})
.end(function (err, res) {
if (err || res.status > 399) {
deferred.reject(err || new Error(res.status));
return;
}
var response = res.body;
// Get the first one
var hash = Object.keys(response)[0];
self.logger.debug('putFile - result', hash);
deferred.resolve(hash);
});
}
return deferred.promise.nodeify(callback);
};
BlobClient.prototype._setAuthHeaders = function (req) {
if (this.apiToken) {
req.set('x-api-token', this.apiToken);
} else if (this.webgmeToken) {
req.set('Authorization', 'Bearer ' + this.webgmeToken);
}
};
BlobClient.prototype.putMetadata = function (metadataDescriptor, callback) {
var metadata = new BlobMetadata(metadataDescriptor),
deferred = Q.defer(),
self = this,
blob,
contentLength,
req;
// FIXME: in production mode do not indent the json file.
this.logger.debug('putMetadata', {metadata: metadataDescriptor});
if (typeof Blob !== 'undefined' && typeof window !== 'undefined') {
// This does not work using the "new" Blob class in nodejs - so make sure (for now at least) that
// we running under a brower even though Blob is defined.
// https://nodejs.org/api/buffer.html#class-blob
blob = new Blob([JSON.stringify(metadata.serialize(), null, 4)], {type: 'text/plain'});
contentLength = blob.size;
} else {
blob = Buffer.from(JSON.stringify(metadata.serialize(), null, 4), 'utf8');
contentLength = blob.length;
}
req = superagent.post(this.getCreateURL(metadataDescriptor.name, true));
this._setAuthHeaders(req);
if (typeof window === 'undefined') {
req.agent(this.keepaliveAgent);
req.set('Content-Length', contentLength);
}
req.set('Content-Type', 'application/octet-stream')
.send(blob)
.end(function (err, res) {
if (err || res.status > 399) {
deferred.reject(err || new Error(res.status));
return;
}
// Uploaded.
var response = JSON.parse(res.text);
// Get the first one
var hash = Object.keys(response)[0];
self.logger.debug('putMetadata - result', hash);
deferred.resolve(hash);
});
return deferred.promise.nodeify(callback);
};
/**
* Adds multiple files to the blob storage.
* @param {object.<string, string|Buffer|ArrayBuffer>} o - Keys are file names and values the content.
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with {object}
* <b>fileNamesToMetadataHashes</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.putFiles = function (o, callback) {
var self = this,
deferred = Q.defer(),
error,
filenames = Object.keys(o),
remaining = filenames.length,
hashes = {},
putFile;
if (remaining === 0) {
deferred.resolve(hashes);
}
putFile = function (filename, data) {
self.putFile(filename, data, function (err, hash) {
remaining -= 1;
hashes[filename] = hash;
if (err) {
error = err;
self.logger.error('putFile failed with error', {metadata: err});
}
if (remaining === 0) {
if (error) {
deferred.reject(error);
} else {
deferred.resolve(hashes);
}
}
});
};
for (var j = 0; j < filenames.length; j += 1) {
putFile(filenames[j], o[filenames[j]]);
}
return deferred.promise.nodeify(callback);
};
BlobClient.prototype.getSubObject = function (hash, subpath, callback) {
return this.getObject(hash, callback, subpath);
};
/**
* Retrieves object from blob storage as a Buffer under node and as an ArrayBuffer in the client.
* N.B. if the retrieved file is a json-file and running in a browser, the content will be decoded and
* the string parsed as a JSON.
* @param {string} metadataHash - hash of metadata for object.
* @param {function} [callback] - if provided no promise will be returned.
* @param {string} [subpath] - optional file-like path to sub-object if complex blob
*
* @return {external:Promise} On success the promise will be resolved with {Buffer|ArrayBuffer|object}
* <b>content</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.getObject = function (metadataHash, callback, subpath) {
var deferred = Q.defer(),
self = this;
this.logger.debug('getObject', metadataHash, subpath);
superagent.parse['application/zip'] = function (obj, parseCallback) {
if (parseCallback) {
// Running on node; this should be unreachable due to req.pipe() below
} else {
return obj;
}
};
//superagent.parse['application/json'] = superagent.parse['application/zip'];
var req = superagent.get(this.getViewURL(metadataHash, subpath));
this._setAuthHeaders(req);
if (typeof window === 'undefined') {
// running on node
req.agent(this.keepaliveAgent);
var Writable = require('stream').Writable;
var BuffersWritable = function (options) {
Writable.call(this, options);
var self = this;
self.buffers = [];
};
require('util').inherits(BuffersWritable, Writable);
BuffersWritable.prototype._write = function (chunk, encoding, cb) {
this.buffers.push(chunk);
cb();
};
var buffers = new BuffersWritable();
buffers.on('finish', function () {
if (req.req.res.statusCode > 399) {
deferred.reject(new Error(req.req.res.statusCode));
} else {
deferred.resolve(Buffer.concat(buffers.buffers));
}
});
buffers.on('error', function (err) {
deferred.reject(err);
});
req.pipe(buffers);
} else {
req.removeAllListeners('end');
req.on('request', function () {
if (typeof this.xhr !== 'undefined') {
this.xhr.responseType = 'arraybuffer';
}
});
// req.on('error', callback);
req.on('end', function () {
if (req.xhr.status > 399) {
deferred.reject(new Error(req.xhr.status));
} else {
var contentType = req.xhr.getResponseHeader('content-type');
var response = req.xhr.response; // response is an arraybuffer
if (contentType === 'application/json') {
response = JSON.parse(UINT.uint8ArrayToString(new Uint8Array(response)));
}
self.logger.debug('getObject - result', {metadata: response});
deferred.resolve(response);
}
});
// TODO: Why is there an end here too? Isn't req.on('end',..) enough?
req.end(function (err, result) {
if (err) {
deferred.reject(err);
} else {
self.logger.debug('getObject - result', {metadata: result});
deferred.resolve(result);
}
});
}
return deferred.promise.nodeify(callback);
};
/**
* If running under nodejs and getting large objects use this method to pipe the downloaded
* object to your provided writeStream.
* @example
* // Piping object to the filesystem..
* var writeStream = fs.createWriteStream('my.zip');
*
* writeStream.on('error', function (err) {
* // handle error
* });
*
* writeStream.on('finish', function () {
* // my.zip exists at this point
* });
*
* blobClient.getStreamObject(metadataHash, writeStream);
*
* @param {string} metadataHash - hash of metadata for object.
* @param {stream.Writable} writeStream - stream the requested data will be piped to.
* @param {string} [subpath] - optional file-like path to sub-object if complex blob
*/
BlobClient.prototype.getStreamObject = function (metadataHash, writeStream, subpath) {
this.logger.debug('getStreamObject', metadataHash, subpath);
var req = superagent.get(this.getViewURL(metadataHash, subpath));
this._setAuthHeaders(req);
if (typeof Buffer !== 'undefined') {
// running on node
req.agent(this.keepaliveAgent);
req.pipe(writeStream);
} else {
throw new Error('streamObject only supported under nodejs, use getObject instead.');
}
};
/**
* Retrieves object from blob storage and parses the content as a string.
* @param {string} metadataHash - hash of metadata for object.
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with {string} <b>contentString</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.getObjectAsString = function (metadataHash, callback) {
var self = this;
return self.getObject(metadataHash)
.then(function (content) {
if (typeof content === 'string') {
// This does currently not happen..
return content;
} else if (typeof Buffer !== 'undefined' && content instanceof Buffer) {
return UINT.uint8ArrayToString(new Uint8Array(content));
} else if (content instanceof ArrayBuffer) {
return UINT.uint8ArrayToString(new Uint8Array(content));
} else if (content !== null && typeof content === 'object') {
return JSON.stringify(content);
} else {
throw new Error('Unknown content encountered: ' + content);
}
})
.nodeify(callback);
};
/**
* Retrieves object from blob storage and parses the content as a JSON. (Will resolve with error if not valid JSON.)
* @param {string} metadataHash - hash of metadata for object.
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with {object} <b>contentJSON</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.getObjectAsJSON = function (metadataHash, callback) {
var self = this;
return self.getObject(metadataHash)
.then(function (content) {
if (typeof content === 'string') {
// This does currently not happen..
return JSON.parse(content);
} else if (typeof Buffer !== 'undefined' && content instanceof Buffer) {
return JSON.parse(UINT.uint8ArrayToString(new Uint8Array(content)));
} else if (content instanceof ArrayBuffer) {
return JSON.parse(UINT.uint8ArrayToString(new Uint8Array(content)));
} else if (content !== null && typeof content === 'object') {
return content;
} else {
throw new Error('Unknown content encountered: ' + content);
}
})
.nodeify(callback);
};
/**
* Retrieves metadata from blob storage.
* @param {string} metadataHash - hash of metadata.
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with {object} <b>metadata</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.getMetadata = function (metadataHash, callback) {
var req = superagent.get(this.getMetadataURL(metadataHash)),
deferred = Q.defer(),
self = this;
this.logger.debug('getMetadata', metadataHash);
this._setAuthHeaders(req);
if (typeof window === 'undefined') {
req.agent(this.keepaliveAgent);
}
req.end(function (err, res) {
if (err || res.status > 399) {
deferred.reject(err || new Error(res.status));
} else {
self.logger.debug('getMetadata', res.text);
deferred.resolve(JSON.parse(res.text));
}
});
return deferred.promise.nodeify(callback);
};
/**
* Creates a new artifact and adds it to array of artifacts of the instance.
* @param {string} name - Name of artifact
* @return {Artifact}
*/
BlobClient.prototype.createArtifact = function (name) {
var artifact = new Artifact(name, this);
this.artifacts.push(artifact);
return artifact;
};
/**
* Retrieves the {@link Artifact} from the blob storage.
* @param {hash} metadataHash - hash associated with the artifact.
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with
* {@link Artifact} <b>artifact</b>.<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.getArtifact = function (metadataHash, callback) {
// TODO: get info check if complex flag is set to true.
// TODO: get info get name.
var self = this,
deferred = Q.defer();
this.logger.debug('getArtifact', metadataHash);
this.getMetadata(metadataHash, function (err, info) {
if (err) {
deferred.reject(err);
return;
}
self.logger.debug('getArtifact - return', {metadata: info});
if (info.contentType === BlobMetadata.CONTENT_TYPES.COMPLEX) {
var artifact = new Artifact(info.name, self, info);
self.artifacts.push(artifact);
deferred.resolve(artifact);
} else {
deferred.reject(new Error('not supported contentType ' + JSON.stringify(info, null, 4)));
}
});
return deferred.promise.nodeify(callback);
};
/**
* Saves all the artifacts associated with the current instance.
* @param {function} [callback] - if provided no promise will be returned.
*
* @return {external:Promise} On success the promise will be resolved with
* {string[]} <b>artifactHashes</b> (metadataHashes).<br>
* On error the promise will be rejected with {@link Error} <b>error</b>.
*/
BlobClient.prototype.saveAllArtifacts = function (callback) {
var promises = [];
for (var i = 0; i < this.artifacts.length; i += 1) {
promises.push(this.artifacts[i].save());
}
return Q.all(promises).nodeify(callback);
};
/**
* Converts bytes to a human readable string.
* @param {number} - File size in bytes.
* @param {boolean} [si] - If true decimal conversion will be used (by default binary is used).
* @returns {string}
*/
BlobClient.prototype.getHumanSize = function (bytes, si) {
var thresh = si ? 1000 : 1024,
units = si ?
['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] :
['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'],
u = -1;
if (bytes < thresh) {
return bytes + ' B';
}
do {
bytes = bytes / thresh;
u += 1;
} while (bytes >= thresh);
return bytes.toFixed(1) + ' ' + units[u];
};
BlobClient.prototype.setToken = function (token) {
this.webgmeToken = token;
};
return BlobClient;
});