UNPKG

@ikigai-gfc/tiny-storage-client

Version:

Tiny node client to request distributed AWS S3 or the OpenStack Swift Object Storage.

709 lines (613 loc) 23 kB
const get = require('simple-get'); const fs = require('fs'); const { Readable } = require('stream'); let _config = { storages: [], activeStorage: 0, endpoints: {}, token: '', timeout: 5000 } /** * @description Authenticate and initialise the auth token and retreive the endpoint based on the region * * @param {function} callback function(err):void = The `err` is null by default, return an object if an error occurs. */ function connection (callback, originStorage = 0) { const arrayArguments = [callback, originStorage]; if (_config.activeStorage === _config.storages.length) { /** Reset the index of the actual storage */ _config.activeStorage = 0; log(`Object Storages are not available`, 'error'); return callback(new Error('Object Storages are not available')); } const _storage = _config.storages[_config.activeStorage]; log(`Object Storage index "${_config.activeStorage}" region "${_storage.region}" connection...`, 'info'); const _json = { auth : { identity : { methods : ['password'], password : { user : { name : _storage.username, domain : { id : 'default' }, password : _storage.password } } }, scope : { project : { domain : { id : 'default' }, name : _storage.tenantName } } } }; get.concat({ url : `${_storage.authUrl}/auth/tokens`, method : 'POST', json : true, body : _json, timeout: _config.timeout }, (err, res, data) => { if (err) { log(`Object Storage index "${_config.activeStorage}" region "${_storage.region}" Action "connection" ${err.toString()}`, 'error'); activateFallbackStorage(originStorage); arrayArguments[1] = _config.activeStorage; return connection.apply(null, arrayArguments); } if (res.statusCode < 200 || res.statusCode >= 300) { log(`Object Storage index "${_config.activeStorage}" region "${_storage.region}" connexion failled | Status ${res.statusCode.toString()} | Message: ${res.statusMessage}`, 'error'); activateFallbackStorage(originStorage); arrayArguments[1] = _config.activeStorage; return connection.apply(null, arrayArguments); } _config.token = res.headers['x-subject-token']; const _serviceCatalog = data.token.catalog.find((element) => { return element.type === 'object-store'; }); if (!_serviceCatalog) { log(`Object Storage index "${_config.activeStorage}" region "${_storage.region}" Storage catalog not found`, 'error'); activateFallbackStorage(originStorage); arrayArguments[1] = _config.activeStorage; return connection.apply(null, arrayArguments); } _config.endpoints = _serviceCatalog.endpoints.find((element) => { return element.region === _storage.region; }); if (!_config.endpoints) { log(`Object Storage index "${_config.activeStorage}" region "${_storage.region} Storage endpoint not found, invalid region`, 'error'); activateFallbackStorage(originStorage); arrayArguments[1] = _config.activeStorage; return connection.apply(null, arrayArguments); } log(`Object Storage index "${_config.activeStorage}" region "${_storage.region}" connected!`, 'info'); return callback(null); }); } /** * @description List objects from a container. It is possible to pass as a second argument as an object with queries or headers to overwrite the request. * * @param {String} container container name * @param {Object} options [OPTIONAL]: { headers: {}, queries: {} } List of headers and queries: https://docs.openstack.org/api-ref/object-store/?expanded=show-container-details-and-list-objects-detail#show-container-details-and-list-objects * @param {function} callback function(err, body):void = The second argument `body` is the content of the file as a Buffer. The `err` argument is null by default, return an object if an error occurs. */ function listFiles(container, options, callback) { const arrayArguments = [...arguments]; if (callback === undefined) { callback = options; arrayArguments.push(options); options = { headers: {}, queries: {} }; arrayArguments[1] = options; } arrayArguments.push({ originStorage : _config.activeStorage }) const { headers, queries } = getHeaderAndQueryParameters(options); get.concat({ url : `${_config.endpoints.url}/${container}${queries}`, method : 'GET', headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json', ...headers }, timeout: _config.timeout }, (err, res, body) => { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 ? err.toString() : null; checkIsConnected(res, 'listFiles', arrayArguments, (error) => { if (error) { return callback(error); } if (res && res.statusCode === 404) { return callback(new Error('Container does not exist')); } err = err || checkResponseError(res); /** TODO: remove? it should never happen as every error switch to another storage */ if (err) { return callback(err); } return callback(null, body); }); }); } /** * @description Save a file on the OVH Object Storage * * @param {string} container Container name * @param {string} filename file to store * @param {string|Buffer} localPathOrBuffer absolute path to the file * @param {Object} options [OPTIONAL]: { headers: {}, queries: {} } List of query parameters and headers: https://docs.openstack.org/api-ref/object-store/?expanded=create-or-replace-object-detail#create-or-replace-object * @param {function} callback function(err):void = The `err` is null by default, return an object if an error occurs. * @returns {void} */ function uploadFile (container, filename, localPathOrBuffer, options, callback) { let readStream = Buffer.isBuffer(localPathOrBuffer) === true ? Readable.from(localPathOrBuffer) : fs.createReadStream(localPathOrBuffer); const arrayArguments = [...arguments]; if (callback === undefined) { callback = options; arrayArguments.push(options); options = { headers: {}, queries: {} }; arrayArguments[3] = options; } arrayArguments.push({ originStorage : _config.activeStorage }) const { headers, queries } = getHeaderAndQueryParameters(options); get.concat({ url : `${_config.endpoints.url}/${container}/${filename}${queries}`, method : 'PUT', body : readStream, headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json', ...headers }, timeout: _config.timeout }, (err, res, body) => { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 && err.code !== 'ENOENT' ? err.toString() : null; checkIsConnected(res, 'uploadFile', arrayArguments, (error) => { if (error) { return callback(error); } err = err || checkResponseError(res, body.toString()); if (err) { if (err.code === 'ENOENT') { return callback(new Error('The local file does not exist')); } return callback(err); } return callback(null); }); }); } /** * @description Download a file from the OVH Object Storage * * @param {string} container Container name * @param {string} filename filename to download * @param {function} callback function(err, body):void = The second argument `body` is the content of the file as a Buffer. The `err` argument is null by default, return an object if an error occurs. * @returns {void} */ function downloadFile (container, filename, callback) { const arrayArguments = [...arguments, { originStorage : _config.activeStorage }]; get.concat({ url : `${_config.endpoints.url}/${container}/${filename}`, method : 'GET', headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json' }, timeout: _config.timeout }, (err, res, body) => { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 ? err.toString() : null; checkIsConnected(res, 'downloadFile', arrayArguments, (error) => { if (error) { return callback(error); } if (res && res.statusCode === 404) { return callback(new Error('File does not exist')); } err = err || checkResponseError(res); /** TODO: remove? it should never happen as every error switch to another storage */ if (err) { return callback(err); } return callback(null, body, res.headers); }); }); } /** * @description Delete a file from the OVH Object Storage * * @param {string} container Container name * @param {string} filename filename to store * @param {function} callback function(err):void = The `err` argument is null by default, return an object if an error occurs. * @returns {void} */ function deleteFile (container, filename, callback) { const arrayArguments = [...arguments, { originStorage : _config.activeStorage }]; get.concat({ url : `${_config.endpoints.url}/${container}/${filename}`, method : 'DELETE', headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json' }, timeout: _config.timeout }, (err, res) => { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 ? err.toString() : null; checkIsConnected(res, 'deleteFile', arrayArguments, (error) => { if (error) { return callback(error); } if (res && res.statusCode === 404) { return callback(new Error('File does not exist')); } err = err || checkResponseError(res); /** TODO: remove? it should never happen as every error switch to another storage */ if (err) { return callback(err); } return callback(null); }); }); } /** * @description Get object metadata * * @param {string} container Container name * @param {string} filename filename to store * @param {function} callback function(err, headers):void = The `err` argument is null by default, return an object if an error occurs. * @returns {void} */ function getFileMetadata(container, filename, callback) { const arrayArguments = [...arguments, { originStorage : _config.activeStorage }]; get.concat({ url : `${_config.endpoints.url}/${container}/${filename}`, method : 'HEAD', headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json' }, timeout: _config.timeout }, (err, res) => { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 ? err.toString() : null; checkIsConnected(res, 'getFileMetadata', arrayArguments, (error) => { if (error) { return callback(error); } if (res && res.statusCode === 404) { return callback(new Error('File does not exist')); } err = err || checkResponseError(res); /** TODO: remove? it should never happen as every error switch to another storage */ if (err) { return callback(err); } return callback(null, res.headers); }); }); } /** * @description Create or update object metadata. * @description To create or update custom metadata * @description use the X-Object-Meta-name header, * @description where name is the name of the metadata item. * * @param {string} container Container name * @param {string} filename file to store * @param {string|Buffer} localPathOrBuffer absolute path to the file * @param {Object} options { headers: {}, queries: {} } List of query parameters and headers: https://docs.openstack.org/api-ref/object-store/?expanded=create-or-update-object-metadata-detail#create-or-update-object-metadata * @param {function} callback function(err, headers):void = The `err` is null by default, return an object if an error occurs. * @returns {void} */ function setFileMetadata (container, filename, options, callback) { const arrayArguments = [...arguments]; if (callback === undefined) { callback = options; arrayArguments.push(options); options = { headers: {}, queries: {} }; arrayArguments[3] = options; } arrayArguments.push({ originStorage : _config.activeStorage }) const { headers, queries } = getHeaderAndQueryParameters(options); get.concat({ url : `${_config.endpoints.url}/${container}/${filename}${queries}`, method : 'POST', headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json', ...headers }, timeout: _config.timeout }, (err, res) => { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 ? err.toString() : null; checkIsConnected(res, 'setFileMetadata', arrayArguments, (error) => { if (error) { return callback(error); } if (res && res.statusCode === 404) { return callback(new Error('File does not exist')); } err = err || checkResponseError(res); /** TODO: remove? it should never happen as every error switch to another storage */ if (err) { return callback(err); } return callback(null, res.headers); }); }); } /** * @description Send a custom request to the object storage * * @param {string} method HTTP method used (POST, COPY, etc...) * @param {string} path path requested, passing an empty string will request the account details. For container request pass the container name, such as: '/containerName'. For file request, pass the container and the file, such as: '/container/filename.txt'. * @param {Object} options { headers: {}, queries: {}, body: '' } Pass to the request the body, query parameters and/or headers. List of headers: https://docs.openstack.org/api-ref/object-store/?expanded=create-or-update-object-metadata-detail#create-or-update-object-metadata * @param {function} callback function(err, body, headers):void = The `err` is null by default. * @returns {void} */ function request (method, path, options, callback) { const arrayArguments = [...arguments]; if (callback === undefined) { callback = options; arrayArguments.push(options); options = { headers: {}, queries: {}, body: null }; arrayArguments[3] = options; } arrayArguments.push({ originStorage : _config.activeStorage }) const { headers, queries, body } = getHeaderAndQueryParameters(options); const _requestOptions = { url : `${_config.endpoints.url}${path}${queries}`, method : method, headers : { 'X-Auth-Token' : _config.token, Accept : 'application/json', ...headers }, timeout: _config.timeout, ...(body ? { body } : {}) } const _requestCallback = function (err, res, body) { /** Manage special errors: timeouts, too many redirects or any unexpected behavior */ res = res || {}; res.error = err && err.toString().length > 0 ? err.toString() : null; checkIsConnected(res, 'request', arrayArguments, (error) => { if (error) { return callback(error); } err = err || checkResponseError(res); /** TODO: remove? it should never happen as every error switch to another storage */ if (err) { return callback(err); } return options?.stream === true ? callback(null, res) : callback(null, body, res.headers); }); } return options?.stream === true ? get(_requestOptions, _requestCallback) : get.concat(_requestOptions, _requestCallback); } /** * @description Check the response status code and return an Error. * * @param {Object} response Response object from request * @returns {null|Error} */ function checkResponseError (response, body = '') { /** TODO: remove? it should never happen as every error switch to another storage */ if (!response) { return new Error('No response'); } if (response.statusCode < 200 || response.statusCode >= 300) { return new Error(`${response.statusCode.toString()} ${response.statusMessage || body}`); } return null; } /** * @description Check if the request is authorized, if not, it authenticate again to generate a new token, and execute again the initial request. * * @param {Object} response Request response * @param {String} from Original function called * @param {Object} args Arguments of the original function. * @param {function} callback function(err):void = The `err` argument is null by default, return an object if an error occurs. * @returns {void} */ function checkIsConnected (response, from, args, callback) { if (!response || (response?.statusCode < 500 && response?.statusCode !== 401) || (!response?.statusCode && !!response?.error !== true)) { return callback(null); } if (response?.statusCode >= 500) { log(`Object Storage index "${_config.activeStorage}" region "${_config.storages[_config.activeStorage].region}" Action "${from}" Status ${response?.statusCode}`, 'error'); activateFallbackStorage(args[args.length - 1].originStorage); } if (!!response?.error === true) { log(`Object Storage index "${_config.activeStorage}" region "${_config.storages[_config.activeStorage].region}" Action "${from}" ${response.error}`, 'error'); activateFallbackStorage(args[args.length - 1].originStorage); } if (response?.statusCode === 401) { log(`Object Storage index "${_config.activeStorage}" region "${_config.storages[_config.activeStorage].region}" try reconnect...`, 'info'); } // Reconnect to object storage connection((err) => { if (err) { return callback(err); } switch (from) { case 'downloadFile': downloadFile.apply(null, args); break; case 'uploadFile': uploadFile.apply(null, args); break; case 'deleteFile': deleteFile.apply(null, args); break; case 'listFiles': listFiles.apply(null, args); break; case 'getFileMetadata': getFileMetadata.apply(null, args); break; case 'setFileMetadata': setFileMetadata.apply(null, args); break; case 'request': request.apply(null, args); break; default: /** TODO: remove? it should never happen */ return callback(null); } }, args[args.length - 1].originStorage); } /** * @description Set and overwrite the Object Storage SDK configurations * * @param {Object} config * @param {String} config.authUrl URL used for authentication, default: "https://auth.cloud.ovh.net/v3" * @param {String} config.username Username for authentication * @param {String} config.password Password for authentication * @param {String} config.tenantName Tenant Name/Tenant ID for authentication * @param {String} config.region Region used to retreive the Object Storage endpoint to request */ function setStorages(storages) { _config.token = ''; _config.endpoints = {}; _config.activeStorage = 0; if (Array.isArray(storages) === true) { /** List of storage */ _config.storages = storages; } else if (typeof storages === 'object') { /** Only a single storage is passed */ _config.storages = []; _config.storages.push(storages) } } /** * Set the timeout * * @param {Integer} timeout */ function setTimeout(timeout) { _config.timeout = timeout; } /** * @description Return the list of storages * * @returns {String} The list of storages */ function getStorages() { return _config.storages; } /** * @description Return the configuration object * * @returns {String} The list of storages */ function getConfig() { return _config; } /** * log messages * * @param {String} msg Message * @param {type} type warning, error */ function log(msg, level = 'info') { return console.log(level === 'error' ? `❗️ Error: ${msg}` : level === 'warning' ? `⚠️ ${msg}` : msg ); } /** * Override the log function, it takes to arguments: message, level * @param {Function} newLogFunction (message, level) => {} The level can be: `info`, `warning`, `error` */ function setLogFunction (newLogFunction) { if (newLogFunction) { // eslint-disable-next-line no-func-assign log = newLogFunction; } } /** * * @description Initialise and return an instance of the Object Storage SDK. * * @param {Object} config * @param {String} config.authUrl URL used for authentication, default: "https://auth.cloud.ovh.net/v3" * @param {String} config.username Username for authentication * @param {String} config.password Password for authentication * @param {String} config.tenantName Tenant Name/Tenant ID for authentication * @param {String} config.region Region used to retreive the Object Storage endpoint to request */ module.exports = (config) => { setStorages(config) return { connection, uploadFile, downloadFile, deleteFile, listFiles, getFileMetadata, setFileMetadata, setTimeout, setStorages, getStorages, getConfig, setLogFunction, request } } /** ============ Utils =========== */ /** * Convert an Object of query parameters into a string * Example: { "prefix" : "user_id_1234", "format" : "xml"} => "?prefix=user_id_1234&format=xml" * * @param {Object} queries * @returns */ function getQueryParameters (queries) { let _queries = ''; if (queries && typeof queries === "object") { const _queriesEntries = Object.keys(queries); const _totalQueries = _queriesEntries.length; for (let i = 0; i < _totalQueries; i++) { if (i === 0) { _queries += '?' } _queries += `${_queriesEntries[i]}=${queries[_queriesEntries[i]]}` if (i + 1 !== _totalQueries) { _queries += '&' } } } return _queries; } function getHeaderAndQueryParameters (options) { let headers = {}; let queries = ''; let body = null if (options?.queries) { queries = getQueryParameters(options.queries); } if (options?.headers) { headers = options.headers; } if (options?.body) { body = options.body; } return { headers, queries, body } } function activateFallbackStorage(originStorage) { if (originStorage === _config.activeStorage && _config.activeStorage + 1 <= _config.storages.length) { _config.activeStorage += 1; log(`Object Storage Activate Fallback Storage index "${_config.activeStorage}" 🚩`, 'warning'); } }