major revamp of http client functionalit

This commit is contained in:
Dave Horton
2020-02-14 12:45:28 -05:00
parent ff531e6964
commit 446000ee97
35 changed files with 906 additions and 433 deletions

10
lib/utils/basic-auth.js Normal file
View File

@@ -0,0 +1,10 @@
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
module.exports = (auth) => {
if (!auth || !auth.username ||
typeof auth.username !== 'string' ||
(auth.password && typeof auth.password !== 'string')) return {};
const creds = `${auth.username}:${auth.password || ''}`;
const header = `Basic ${toBase64(creds)}`;
return {Authorization: header};
};

View File

@@ -0,0 +1,42 @@
class DtmfEntry {
constructor(key, time) {
this.key = key;
this.time = time;
}
}
/**
* @classdesc Represents an object that collects dtmf key entries and
* reports when a match is detected
*/
class DtmfCollector {
constructor({logger, patterns, interDigitTimeout}) {
this.logger = logger;
this.patterns = patterns;
this.idt = interDigitTimeout || 3000;
this.buffer = [];
}
keyPress(key) {
const now = Date.now();
// age out previous entries if interdigit timer has elapsed
const lastDtmf = this.buffer.pop();
if (lastDtmf) {
if (now - lastDtmf.time < this.idt) this.buffer.push(lastDtmf);
else {
this.buffer = [];
}
}
// add new entry
this.buffer.push(new DtmfEntry(key, now));
// check for a match
const collectedDigits = this.buffer
.map((entry) => entry.key)
.join('');
return this.patterns.find((pattern) => collectedDigits.endsWith(pattern));
}
}
module.exports = DtmfCollector;

View File

@@ -1,52 +0,0 @@
const request = require('request');
//require('request-debug')(request);
const retrieveApp = require('./retrieve-app');
function hooks(logger, callInfo) {
function actionHook(hook, obj = {}, expectResponse = true) {
const method = (hook.method || 'POST').toUpperCase();
const auth = (hook.username && hook.password) ?
{username: hook.username, password: hook.password} :
null;
const data = Object.assign({}, obj, callInfo.toJSON());
logger.debug({hook, data, auth}, 'actionhook');
/* customer data only on POSTs */
if ('GET' === method) delete data.customerData;
const opts = {
url: hook.url,
method,
json: 'POST' === method || expectResponse
};
if (auth) opts.auth = auth;
if ('POST' === method) opts.body = data;
else opts.qs = data;
return new Promise((resolve, reject) => {
request(opts, (err, response, body) => {
if (err) {
logger.info(`actionHook error ${method} ${hook.url}: ${err.message}`);
return reject(err);
}
if (body && expectResponse) {
logger.debug(body, `actionHook response ${method} ${hook.url}`);
return resolve(retrieveApp(logger, body));
}
resolve(body);
});
});
}
function notifyHook(hook, opts = {}) {
return actionHook(hook, opts, false);
}
return {
actionHook,
notifyHook
};
}
module.exports = hooks;

View File

@@ -5,9 +5,7 @@ const {TaskPreconditions, CallDirection} = require('../utils/constants');
const CallInfo = require('../session/call-info');
const assert = require('assert');
const ConfirmCallSession = require('../session/confirm-call-session');
const hooks = require('./notifiers');
const moment = require('moment');
const parseUrl = require('parse-url');
const uuidv4 = require('uuid/v4');
class SingleDialer extends Emitter {
@@ -20,8 +18,7 @@ class SingleDialer extends Emitter {
this.sbcAddress = sbcAddress;
this.opts = opts;
this.application = application;
this.url = target.url;
this.method = target.method;
this.confirmHook = target.confirmHook;
this.bindings = logger.bindings();
@@ -37,6 +34,22 @@ class SingleDialer extends Emitter {
return this.callInfo.callStatus;
}
/**
* can be used for all http requests within this session
*/
get requestor() {
assert(this.application.requestor);
return this.application.requestor;
}
/**
* can be used for all http call status notifications within this session
*/
get notifier() {
assert(this.application.notifier);
return this.application.notifier;
}
async exec(srf, ms, opts) {
let uri, to;
switch (this.target.type) {
@@ -106,9 +119,6 @@ class SingleDialer extends Emitter {
callId: this.callInfo.callId
});
this.inviteInProgress = req;
const {actionHook, notifyHook} = hooks(this.logger, this.callInfo);
this.actionHook = actionHook;
this.notifyHook = notifyHook;
this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
},
cbProvisional: (prov) => {
@@ -135,7 +145,7 @@ class SingleDialer extends Emitter {
this.ep.destroy();
});
if (this.url) this._executeApp(this.url);
if (this.confirmHook) this._executeApp(this.confirmHook);
else this.emit('accept');
} catch (err) {
const status = {callStatus: CallStatus.Failed};
@@ -178,29 +188,12 @@ class SingleDialer extends Emitter {
* Note: the application to run may not include a dial or sip:decline verb
* @param {*} url - url for application
*/
async _executeApp(url) {
this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`);
async _executeApp(confirmHook) {
try {
let auth, method;
const app = Object.assign({}, this.application);
if (url.startsWith('/')) {
const savedUrl = url;
const or = app.originalRequest;
url = `${or.baseUrl}${url}`;
auth = or.auth;
method = this.method || or.method || 'POST';
this.logger.debug({originalUrl: savedUrl, normalizedUrl: url}, 'SingleDialer:_executeApp normalized url');
}
else {
const u = parseUrl(url);
const myPort = u.port ? `:${u.port}` : '';
app.originalRequest = {
baseUrl: `${u.protocol}://${u.resource}${myPort}`
};
method = this.method || 'POST';
}
// retrieve set of tasks
const tasks = await this.requestor.request(confirmHook, this.callInfo);
const tasks = await this.actionHook({url, method, auth});
// verify it contains only allowed verbs
const allowedTasks = tasks.filter((task) => {
return [
TaskPreconditions.StableCall,
@@ -211,16 +204,19 @@ class SingleDialer extends Emitter {
throw new Error('unsupported verb in dial url');
}
// now execute it in a new ConfirmCallSession
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
const cs = new ConfirmCallSession({
logger: this.logger,
application: app,
application: this.application,
dlg: this.dlg,
ep: this.ep,
callInfo: this.callInfo,
tasks
});
await cs.exec();
// still connected after app is completed? Signal parent call we are good
this.emit(this.dlg.connected ? 'accept' : 'decline');
} catch (err) {
this.logger.debug(err, 'SingleDialer:_executeApp: error');
@@ -233,6 +229,7 @@ class SingleDialer extends Emitter {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed');
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {

64
lib/utils/requestor.js Normal file
View File

@@ -0,0 +1,64 @@
const bent = require('bent');
const parseUrl = require('parse-url');
const basicAuth = require('./basic-auth');
const assert = require('assert');
function isRelativeUrl(u) {
return typeof u === 'string' && u.startsWith('/');
}
function isAbsoluteUrl(u) {
return typeof u === 'string' &&
u.startsWith('https://') || u.startsWith('http://');
}
class Requestor {
constructor(logger, hook) {
this.logger = logger;
this.url = hook.url;
this.method = hook.method || 'POST';
this.authHeader = basicAuth(hook.auth);
const u = parseUrl(this.url);
const myPort = u.port ? `:${u.port}` : '';
const baseUrl = `${u.protocol}://${u.resource}${myPort}`;
this.get = bent(baseUrl, 'GET', 'json', 200);
this.post = bent(baseUrl, 'POST', 'json', 200);
assert(isAbsoluteUrl(this.url));
assert(['GET', 'POST'].includes(this.method));
assert(!this.auth || typeof auth == 'object');
}
get hasAuth() {
return 'Authorization' in this.authHeader;
}
/**
* Make an HTTP request.
* All requests use json bodies.
* All requests expect a 200 statusCode on success
* @param {object|string} hook - may be a absolute or relative url, or an object
* @param {string} [hook.url] - an absolute or relative url
* @param {string} [hook.method] - 'GET' or 'POST'
* @param {object} [params] - request parameters
*/
async request(hook, params) {
params = params || null;
if (isRelativeUrl(hook)) {
this.logger.debug({params}, `Requestor:request relative url ${hook}`);
return await this.post(hook, params, this.authHeader);
}
const url = hook.url;
const method = hook.method || 'POST';
const authHeader = isRelativeUrl(url) ? this.authHeader : basicAuth(hook.auth);
assert(url);
assert(['GET', 'POST'].includes(method));
return await this[method.toLowerCase()](url, params, authHeader);
}
}
module.exports = Requestor;

View File

@@ -1,31 +0,0 @@
const request = require('request');
//require('request-debug')(request);
const makeTask = require('../tasks/make_task');
const normalizeJamones = require('./normalize-jamones');
function retrieveUrl(logger, url, method, auth, obj) {
const opts = {url, method, auth, json: true};
if (method === 'GET') Object.assign(opts, {qs: obj});
else Object.assign(opts, {body: obj});
return new Promise((resolve, reject) => {
request(opts, (err, response, body) => {
if (err) throw err;
if (response.statusCode === 401) return reject(new Error('HTTP request failed: Unauthorized'));
else if (response.statusCode !== 200) return reject(new Error(`HTTP request failed: ${response.statusCode}`));
if (body) logger.debug({body}, 'retrieveUrl: ${method} ${url} returned an application');
resolve(body);
});
});
}
async function retrieveApp(logger, url, method, auth, obj) {
let json;
if (typeof url === 'object') json = url;
else json = await retrieveUrl(logger, url, method, auth, obj);
return normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
}
module.exports = retrieveApp;

View File

@@ -0,0 +1,3 @@
module.exports = function(tasks) {
return `[${tasks.map((t) => t.name).join(',')}]`;
};