|
const axios = require('axios'); |
|
const uuid = require('uuid'); |
|
const R = require('ramda'); |
|
const winston = require('winston'); |
|
const logger = winston.createLogger({ |
|
level: 'debug', |
|
transports: [new winston.transports.Console()] |
|
}).child({service: 'TinyurlApiManager'}); |
|
|
|
const MAX_CONTENT_LEN = 4096; |
|
|
|
/** |
|
* Wraps the tinyurl webservice into a concise and easy to use manager object |
|
*/ |
|
class TinyurlApiManager { |
|
constructor(opts) { |
|
const {baseurl, domain, apikey} = opts; |
|
this._apikey = apikey; |
|
this._domain = domain || "tinyurl.com"; |
|
this._axios = axios.create({ |
|
validateStatus, |
|
baseURL: (baseurl || "https://api.tinyurl.com/"), |
|
maxContentLength: MAX_CONTENT_LEN, |
|
headers: { |
|
common: { |
|
['Accept']: 'application/json', |
|
['Content-Type']: 'application/json' |
|
} |
|
} |
|
}); |
|
// API key is added to each request. It is static, so we can set here. |
|
// If it were dynamic, we would login, retrieve+cache token in a request interceptor |
|
this._axios.defaults.headers.common['Authorization'] = `Bearer ${this._apikey}`; |
|
logger.info("initialized TinyurlApiManager", {baseurl: this._axios.defaults.baseURL, domain: this._domain}); |
|
|
|
// Setup Axios Request Interceptors ------------------- |
|
// Request Interceptor 1 of 1: Add dynamic headers to all requests |
|
this._axios.interceptors.request.use(async config => { |
|
let correlationId = config.headers['x-correlation-id']; |
|
if (correlationId) { |
|
logger.debug("CorrelationId request interceptor: correlationId header already set", { correlationId }); |
|
} else { |
|
// correlation id: default to a random value unless it is already set |
|
config.headers = Object.assign(config.headers, { |
|
['x-correlation-id']: uuid.v4() |
|
}); |
|
logger.debug("CorrelationId request interceptor: Dynamic headers were added to axios config", { |
|
url: config.url, |
|
headers: filterObject(config.headers, ['x-correlation-id']) |
|
}); |
|
} |
|
return config; |
|
}); |
|
|
|
// Setup Axios Response Interceptors ------------------- |
|
// Response Interceptor 1 of 2: Enrich data when appropriate and the 'enrich' param passed |
|
this._axios.interceptors.response.use( |
|
response => { |
|
// if the response payload contains an alias value AND the 'enrich' param is true, add the custom score data to the response |
|
let resAlias = R.path(['data', 'data', 'alias'], response); |
|
let paramEnrich = R.path(['config', 'params', 'enrich'], response); |
|
let doEnrich = !!(resAlias && paramEnrich); |
|
logger.debug("enrichment evaluation", {resAlias, paramEnrich, doEnrich}); |
|
if (doEnrich) { |
|
response.data.data.enriched_custom_score = calculateCustomScore(resAlias); |
|
} |
|
logger.debug("post enrichment", {data:response.data}); |
|
return response; |
|
}); |
|
|
|
// Response Interceptor 2 of 2: Uniform Logging and Error normalization: |
|
// Failed calls return an Error object that contains a custom 'axiosError' attribute. |
|
this._axios.interceptors.response.use( |
|
response => { |
|
logger.info("axios call success.", {baseurl: response.config.baseURL, url: response.config.url, status: response.status, statusText: response.statusText}); |
|
logger.debug("axios call success payload", {data:response.data}); |
|
return response; |
|
}, |
|
async error => { |
|
const axiosErrObj = { |
|
url: R.pathOr('-', ['config', 'url'], error), |
|
method: R.pathOr('-', ['config', 'method'], error), |
|
errmsg: error.message, |
|
errstack: error.stack, |
|
errcode: error.code, |
|
response_status: R.pathOr('-', ['response', 'status'], error), |
|
response_body: R.pathOr('-', ['response', 'data'], error) |
|
}; |
|
logger.warn("Failed axios call", {error, axiosErrObj}); |
|
let e = new Error(`axios error: ${axiosErrObj.errmsg}`); |
|
e.axiosError = axiosErrObj; |
|
return Promise.reject(e); |
|
} |
|
); |
|
} |
|
|
|
// ================================== |
|
// Begin: Public interface |
|
|
|
/** |
|
* Fetch a tinyurl with an alias |
|
* @param {Object} args |
|
* @param {string} args.alias - alias identifier of tinyurl, <b>Required</b>. |
|
* @param {boolean} args.enrich=false - should the response be enriched with custom calculated values? <b>Optional</b> |
|
* @param {string} args.correlationId - the correlation id under which this should be logged and x-correlation-id header, <b>Optional</b><br/> If missing, a random value is generated. |
|
* @returns {Object} {tiny_url, ...} |
|
*/ |
|
async getUrl(args) { |
|
const params = filterObject(args, ['alias', 'enrich']); |
|
const opts = filterObject(args, ['correlationId']); |
|
const path = `/alias/${this._domain}/${params.alias}` |
|
validateTruthy(params.alias, "getUrl: missing required param: alias"); |
|
const response = await this._execAxios( {...opts, path, params}); |
|
const payload = R.path(['data', 'data'], response); |
|
validateTruthy(payload, `getUrl: received empty response from api for alias=${params.alias}`); |
|
return filterObject(payload, ['alias', 'archived', 'url', 'hits', 'tiny_url', 'enriched_custom_score']); |
|
} |
|
|
|
/** |
|
* Create a new tinyurl alias |
|
* @param {Object} args |
|
* @param {string} args.url - target url, <b>Required</b>. |
|
* @param {boolean} args.enrich=false - should the response be enriched with custom calculated values? <b>Optional</b> |
|
* @param {string} args.correlationId - the correlation id under which this should be logged and x-correlation-id header, <b>Optional</b><br/> If missing, a random value is generated. |
|
* @returns {Object} {tiny_url, ...} |
|
*/ |
|
async createUrl(args) { |
|
const params = filterObject(args, ['enrich']); |
|
const opts = filterObject(args, ['correlationId']); |
|
const body = filterObject(args, ['url']); |
|
const path = '/create'; |
|
validateTruthy(body.url, "createUrl: missing required arg: url"); |
|
const response = await this._execAxios( {...opts, method: "post", path, params, data: {...body, domain: this._domain}}); |
|
const payload = R.path(['data', 'data'], response); |
|
validateTruthy(payload, `createUrl: received empty response from api for url=${body.url}`); |
|
return filterObject(payload, ['alias', 'archived', 'url', 'hits', 'tiny_url', 'enriched_custom_score']); |
|
} |
|
|
|
/** |
|
* Update the target url of an existing tinyurl alias |
|
* @param {Object} args |
|
* @param {string} args.alias - alias identifier of tinyurl, <b>Required</b>. |
|
* @param {string} args.url - new target url, Required. |
|
* @param {string} args.correlationId - the correlation id under which this should be logged and x-correlation-id header, <b>Optional</b><br/> If missing, a random value is generated. |
|
* @returns {Object} {url, alias} |
|
*/ |
|
async updateUrl(args) { |
|
const opts = filterObject(args, ['correlationId']); |
|
const body = filterObject(args, ['url', 'alias']); |
|
const path = '/change'; |
|
validateTruthy(R.path(['url'], body), "updateUrl: missing required arg: url"); |
|
validateTruthy(R.path(['alias'], body), "updateUrl: missing required arg: alias"); |
|
const response = await this._execAxios( {...opts, method: "patch", path, data: {...body, domain: this._domain}}); |
|
const payload = R.path(['data', 'data'], response); |
|
validateTruthy(payload, `updateUrl: received empty response from api for alias=${body.alias}`); |
|
// api response is limited |
|
let resp_body = filterObject(payload, ['url']); |
|
return {...resp_body, alias: body.alias}; |
|
} |
|
|
|
/** |
|
* Archive an existing tinyurl |
|
* @param {Object} args |
|
* @param {string} args.alias - alias identifier of tinyurl, <b>Required</b>. |
|
* @param {string} args.correlationId - the correlation id under which this should be logged and x-correlation-id header, <b>Optional</b><br/> If missing, a random value is generated. |
|
* @returns {Object} {tiny_url, alias, archived} |
|
*/ |
|
async archiveUrl(args) { |
|
const opts = filterObject(args, ['correlationId']); |
|
const body = filterObject(args, ['alias']); |
|
const path = '/archive'; |
|
validateTruthy(R.path(['alias'], body), "archiveUrl: missing required arg: alias"); |
|
const response = await this._execAxios( {...opts, method: "patch", path, data: {...body, domain: this._domain}}); |
|
const payload = R.path(['data', 'data'], response); |
|
validateTruthy(payload, `archiveUrl: received empty response from api for alias=${body.alias}`); |
|
return filterObject(payload, ['alias', 'archived', 'tiny_url']); |
|
} |
|
|
|
/** |
|
* Retrieve a list of available tinyurls |
|
* @param {Object} args |
|
* @param {string} args.correlationId - the correlation id under which this should be logged and x-correlation-id header, <b>Optional</b><br/> If missing, a random value is generated. |
|
* @returns {Object} {tiny_url, ...} |
|
*/ |
|
async getAvailable(args) { |
|
return await this._getList('/urls/available', args); |
|
} |
|
|
|
/** |
|
* Retrieve a list of archived tinyurls |
|
* @param {Object} args |
|
* @param {string} args.correlationId - the correlation id under which this should be logged and x-correlation-id header, <b>Optional</b><br/> If missing, a random value is generated. |
|
* @returns {Object} {tiny_url, ...} |
|
*/ |
|
async getArchived(args) { |
|
return await this._getList('/urls/archived', args); |
|
} |
|
|
|
|
|
// End: Public interface |
|
|
|
// ================================== |
|
// Begin: Private member helpers |
|
|
|
// shared axios method exec function |
|
async _execAxios(inOpts) { |
|
// the allowed opts: |
|
const { method, maxContentLength, path, params, data, correlationId } = {...inOpts}; |
|
// path must be present in opts |
|
validateTruthy(path, "path must be a specified option in opts for _execAxios call"); |
|
const url = path; |
|
const headers = {'x-correlation-id': correlationId}; |
|
const config = { method, url, headers, maxContentLength, params, data } ; |
|
logger.debug("_execAxios: Executing an axios call", {config}); |
|
const res = await this._axios.request(config); |
|
const retval = {status: res.status, statusText: res.statusText, data: res.data}; |
|
logger.debug("_execAxios: Response from axios call", retval); |
|
return retval; |
|
} |
|
|
|
async _getList(path, args) { |
|
const opts = filterObject(args, ['correlationId']); |
|
const response = await this._execAxios( {...opts, path}); |
|
const payload = R.path(['data', 'data'], response); |
|
validateIsArray(payload, `getList: received non-array response from api at: ${path}`); |
|
return payload.map(e=>filterObject(e, ['alias', 'archived', 'tiny_url'])); |
|
} |
|
|
|
// End: Private member helpers |
|
} |
|
module.exports = TinyurlApiManager; |
|
|
|
|
|
// ================================== |
|
// various private helper functions |
|
|
|
// define success status codes |
|
function validateStatus(status) { |
|
return status >= 200 && status < 300; |
|
} |
|
|
|
function filterObject(obj, allowedAttributeNames) { |
|
return R.pick(allowedAttributeNames || [], obj || {} ); |
|
} |
|
|
|
function calculateCustomScore(val) { |
|
// this is just an example to show response data enrichment: - calculate a value between 0-100 for val based on characters in val |
|
// irl this would be more meaningful, but it's just a demo |
|
return (""+(val || "")) |
|
.split("") |
|
.map(x=>x.charCodeAt(0)).reduce((acc, val) => acc+val, 0) |
|
% 101; |
|
} |
|
|
|
function validateTruthy(val, msg) { |
|
if (!val) { |
|
throw new Error(msg); |
|
} |
|
} |
|
|
|
function validateIsArray(val, msg) { |
|
if (!Array.isArray(val)) { |
|
throw new Error(msg); |
|
} |
|
} |