@ipsolution/ari-client
Version:
JavaScript client for Asterisk REST Interface. (fork IP Solution)
632 lines (561 loc) • 18.4 kB
JavaScript
/**
* Client to ARI instance behaving as an EventEmitter for web socket events.
* The client contains properties representing ARI resources. Those properties
* themselves will contain properties representing callable operations on those
* resources.
*
* @module ari-client
*
* @copyright 2014, Digium, Inc.
* @license Apache License, Version 2.0
* @author Samuel Fortier-Galarneau <sgalarneau@digium.com>
*/
;
var util = require("util");
var request = require("request");
var url = require("url");
var events = require("events");
var WebSocket = require("ws");
var swagger = require("@ipsolution/swagger-client-fork");
var Promise = require("bluebird");
var _ = require("lodash");
var backoff = require("backoff-func");
var _resources = require("./resources.js");
var _utils = require("./utils.js");
/**
* Client is an Event Emitter that allows root level resources
* to be accessed.
*
* @class Client
* @constructor
* @param {string} url - The URL to the ARI instance
* @param {string} user - The username for the ARI instance
* @param {string} pass - The password for the ARI instance
* @prop {Connection} _connection - connection parts to an ARI instance
* @prop {Object} _instanceListeners - array of instance objects for
* instances that registered for scoped events keyed by event type
*/
function Client(baseUrl, user, pass) {
var self = this;
events.EventEmitter.call(self);
// Store connection settings for prototype methods
var parsedUrl = url.parse(baseUrl);
/**
* Connection parts to an ARI instance.
*
* @class Connection
* @prop {string} protocol
* @prop {string} host
* @prop {string} hostname
* @prop {string} user - username for ARI instance
* @prop {string} pass - password for ARI instance
*/
self._connection = {
protocol: parsedUrl.protocol,
host: parsedUrl.host,
hostname: parsedUrl.hostname,
user: user,
pass: pass,
};
// Keep track of instance event listeners. once true means that the callback
// will only be called once for the given event.
//
// {
// 'eventType': [{'once': false, 'id': uniqueId, 'callback': callback}...],
// ...
// }
self._instanceListeners = {};
}
// Make Client an Event Emitter
util.inherits(Client, events.EventEmitter);
/**
* Attaches the API endpoints and operations for those endpoints to the Client
* instance.
*
* @memberof Client
* @method _attachApi
* @param {attachApiCallback} callback - callback invoked once API attached
* @returns {Q} promise - a promise that will resolve to a client
*/
Client.prototype._attachApi = function () {
var self = this;
return new Promise(function (resolve, reject) {
swagger.authorizations.add(
"basic-auth",
new swagger.PasswordAuthorization(
self._connection.hostname,
self._connection.user,
self._connection.pass
)
);
// Connect to API using swagger and attach resources on Client instance
var ariUrl = util.format(
"%s//%s/ari/api-docs/resources.json",
self._connection.protocol,
self._connection.host
);
request(ariUrl, function (err) {
try {
if (
err &&
[
"ETIMEDOUT",
"ENOTFOUND",
"EPROTO",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"ECONNREFUSED",
].includes(err.code)
) {
err.name = "HostIsNotReachable";
self.emit("APILoadError", err);
reject(err);
} else {
try {
self._swagger = new swagger.SwaggerApi({
url: ariUrl,
success: swaggerLoaded,
failure: function (err) {
console.error("Swagger failed to load:", err);
// Verifica se err é string (Swagger 1.x joga string como erro)
if (typeof err === "string") {
const cleanErr = new Error(err);
cleanErr.name = "SwaggerLoadError";
self.emit("APILoadError", cleanErr);
return reject(cleanErr);
}
// Se já for objeto, segue normalmente
err.name = "SwaggerLoadError";
self.emit("APILoadError", err);
reject(err);
},
});
} catch (e) {
console.error("Erro na criação do SwaggerApi:", e);
const apiErr = new Error("Erro crítico ao inicializar SwaggerApi");
apiErr.original = e;
self.emit("APILoadError", apiErr);
reject(apiErr);
}
}
} catch (e) {
console.error("Erro durante carregamento do Swagger:", e);
reject(e);
}
});
/**
* Success handler for swagger connect.
*
* @callback swaggerConnectSuccessCallback
* @method swaggerLoaded
* @memberof module:ari-client~Client~_attachApi
* @private
*/
function swaggerLoaded() {
if (self._swagger.ready === true) {
// Attach resources to client
_.each(_.keys(self._swagger.apis), attachResource);
// Attach resource creators to client
_.each(_resources.knownTypes, attachResourceCreators);
resolve(self);
}
}
/**
* Failure handler for swagger connect.
*
* @callback swaggerConnectFailureCallback
* @method swaggerFailed
* @memberof module:ari-client~Client~_attachApi
* @private
*/
function swaggerFailed(err) {
self.emit("APILoadError", err);
reject(err);
}
/**
* Attach resource creators to Client instance to enable id generation.
*
* Optionally, a values object can be used to set certain values on the
* resource instance.
*
* If the first argument is a string, it is used as an id.
*
* @callback attachResourceCreatorsCallback
* @method attachResourceCreators
* @memberof module:ari-client~Client~_attachApi
* @private
* @param {string} resourceType - the type of the resource to setup a
* create method for
*/
function attachResourceCreators(resourceType) {
self[resourceType] = function (id, values) {
return _resources[resourceType](self, id, values);
};
}
/**
* Attach resources and operations to Client instance.
*
* @callback attachResourceCallback
* @method attachResource
* @memberof module:ari-client~Client~_attachApi
* @private
* @param {string} resource - the name of the resource
*/
function attachResource(resource) {
self[resource] = {};
var operations = self._swagger.apis[resource].operations;
_.each(_.keys(operations), attachOperation);
// Attach operation to resource
function attachOperation(operation) {
self[resource][operation] = callSwagger;
var oper = self._swagger.apis[resource].operations[operation];
var respType = oper.type;
var multi = false;
var regexArr = _resources.swaggerListTypeRegex.exec(respType);
if (regexArr !== null) {
respType = regexArr[1];
multi = true;
}
var params = oper.parameters;
/**
* Responsible for calling API through Swagger.
*
* @callback attachResourceCallback
* @memberof module:ari-client~Client~_attachApi~attachResource
* @method callSwagger
* @private
* @param {Object} parameters - parameters to swagger
* @param {Function} callback - callback invoked with swagger response
* @returns {Q} promise - a promise that will resolve to a client
*/
function callSwagger(/* args..., callback */) {
var args = _.toArray(arguments);
// Separate user callback from other args
var options = _.first(args);
var userCallback = _.isFunction(_.last(args)) ? _.last(args) : undefined;
return new Promise(function (innerResolve, innerReject) {
args = [];
if (
options === undefined ||
options === null ||
_.isFunction(options) ||
_.isArray(options)
) {
options = {};
} else {
// Swagger can alter options passed in
options = _.clone(options);
// convert body params for swagger
options = _utils.parseBodyParams(params, options);
}
args.push(options);
// Inject response processing callback
args.push(processResponse);
// Inject error handling callback
args.push(swaggerError);
// Run operation against Swagger
self._swagger.apis[resource][operation].apply(null, args);
// Handle error from Swagger
function swaggerError(err) {
if (err && err.data) {
err = new Error(err.data.toString("utf-8"));
}
innerReject(err);
}
/**
* Process response from Swagger.
*
* @callback callSwaggerSuccessCallback
* @method processResponse
* @memberof
* module:ari-client~Client~_attachApi~attachResource~callSwagger
* @private
* @param {Object} response - response from swagger
*/
function processResponse(response) {
var result;
if (respType === "binary") {
result = Buffer.from(response.data);
} else {
result = response.data.toString("utf-8");
if (respType !== null && result) {
result = JSON.parse(result);
}
}
if (
_.includes(_resources.knownTypes, respType) &&
_resources[respType] !== undefined
) {
if (multi) {
result = _.map(result, function (obj) {
return _resources[respType](self, obj);
});
} else {
result = _resources[respType](self, result);
}
}
innerResolve(result);
}
}).asCallback(userCallback);
}
}
}
});
};
/**
* Creates the web socket connection, subscribing to the given apps.
*
* @memberof Client
* @method start
*/
Client.prototype.start = function (apps, subscribeAll, callback) {
var self = this;
// are we currently processing a WebSocket error?
var processingError = false;
// Perform argument renaming for backwards compatibility
if (typeof subscribeAll === "function") {
callback = subscribeAll;
subscribeAll = null;
}
return new Promise(function (resolve, reject) {
// Rewrite resolve/reject functions so they can only be called once and
// each disables the other when called.
resolve = _.once(resolve);
reject = _.once(reject);
var applications = _.isArray(apps) ? apps.join(",") : apps;
var wsUrl = util.format(
"%s://%s/ari/events?app=%s&api_key=%s:%s",
self._connection.protocol === "https:" ? "wss" : "ws",
self._connection.host,
applications,
encodeURIComponent(self._connection.user),
encodeURIComponent(self._connection.pass)
);
if (subscribeAll) {
wsUrl += "&subscribeAll=true";
}
var retry = backoff.create({
delay: 100,
});
connect();
/**
* Connects to the application via WebSocket.
*
* @method connect
* @memberof module:ari-client~Client~start
* @private
*/
function connect() {
self._ws = new WebSocket(wsUrl);
self._ws.on("open", function () {
processOpen();
});
self._ws.on("error", processError);
self._ws.on("message", processMessage);
self._ws.on("pong", processPong);
self._ws.on("close", processClose);
}
/**
* Process pong received by web socket and emit event
*
* @method processPing
* @memberof module:ari-client~Client~start
* @private
*/
function processPong() {
self.emit("pong");
}
/**
* Process message received by web socket and emit event.
*
* @method processMessage
* @memberof module:ari-client~Client~start
* @private
* @param {Object} msg - the web socket message
* @param {Object} flags - web socket control flags
*/
function processMessage(msg, flags) {
var event = {};
if (msg) {
event = JSON.parse(msg);
}
var eventModels = self._swagger.apis.events.models;
var eventModel = _.find(eventModels, function (item, key) {
return key === event.type;
});
var resources = {};
var instanceIds = [];
// Pass in any property that is a known type as an object
_.each(eventModel.properties, function (prop) {
if (
_.includes(_resources.knownTypes, prop.dataType) &&
event[prop.name] !== undefined &&
_resources[prop.dataType] !== undefined
) {
var instance = _resources[prop.dataType](self, event[prop.name]);
resources[prop.name] = instance;
// Keep track of which instance specific events we should
// emit
var listeners = self._instanceListeners[event.type];
var instanceId = instance._id().toString();
if (listeners) {
var updatedListeners = [];
_.each(listeners, function (listener) {
if (listener.id === instanceId) {
// make sure we do not duplicate events for a given instance
if (!_.includes(instanceIds, instanceId)) {
instanceIds.push(instanceId);
}
// remove listeners that should only be invoked once
if (!listener.once) {
updatedListeners.push(listener);
}
} else {
updatedListeners.push(listener);
}
});
self._instanceListeners[event.type] = updatedListeners;
}
}
});
var promoted = _.keys(resources).length;
if (promoted === 1) {
resources = resources[_.keys(resources)[0]];
} else if (promoted === 0) {
resources = undefined;
}
self.emit("*", event, resources);
self.emit(event.type, event, resources);
// If appropriate, emit instance specific events
if (instanceIds.length > 0) {
_.each(instanceIds, function (instanceId) {
self.emit(util.format("%s-%s", event.type, instanceId), event, resources);
});
}
}
/**
* Process open event.
*
* @method processOpen
* @memberof module:ari-client~Client~start
* @private
*/
function processOpen() {
processingError = false;
// reset backoff handler when we successfully connect
retry = backoff.create({
delay: 100,
});
self.emit("WebSocketConnected");
// onced, will not be called when an automatic reconnect succeeds.
resolve();
}
/**
* Process close event. Attempt to reconnect to web socket with a back off
* to ensure we do not flood the server.
*
* @method processClose
* @memberof module:ari-client~Client~start
* @private
* @param {Number} reason - reason code for disconnect
* @param {String} description - reason text for disconnect
*/
function processClose(reason, description) {
// was connection closed on purpose?
if (self._wsClosed) {
self._wsClosed = false;
return;
}
if (!processingError) {
reconnect();
}
}
/**
* Process error event.
*
* @method processError
* @memberof module:ari-client~Client~start
* @private
* @param {Error} err - error object
*/
function processError(err) {
// was connection closed on purpose?
if (self._wsClosed) {
return;
}
processingError = true;
reconnect(err);
}
/**
* Attempts to reconnect to the WebSocket using a backoff function.
*
* @method reconnect
* @memberof module:ari-client~Client~start
* @private
* @param {Error} [err] - error object
*/
function reconnect(err) {
var scheduled = retry(connect);
var msg = err ? err.message : "unknown";
if (!scheduled) {
// onced or disabled if initial connection succeeds.
reject(new Error("Connection attempts exceeded WebSocketMaxRetries. " + msg));
self.emit("WebSocketMaxRetries", err);
} else {
self.emit("WebSocketReconnecting", err);
}
}
}).asCallback(callback);
};
/**
* Closes the web socket connection.
*
* @memberof Client
* @method stop
*/
Client.prototype.stop = function () {
var self = this;
self._ws.close();
self._wsClosed = true;
};
/**
* Pings the web socket
*
* @memberof Client
* @method ping
*/
Client.prototype.ping = function () {
var self = this;
if (self._ws === undefined || self._wsClosed) {
return;
}
self._ws.ping();
};
/**
* Create an instance of Client using the provided connection options and call
* the provided callback once the API has been attached to the Client.
*
* @method connect
* @memberof module:ari-client
* @param {string} url - The URL to the ARI instance
* @param {string} user - The username for the ARI instance
* @param {string} pass - The password for the ARI instance
* @param {Client-clientCallback} cb -
* The callback to be called upon connection
* @returns {Q} promise - a promise that will resolve to a client
*/
module.exports.connect = function (
baseUrl,
user,
pass,
/**
* @callback connectCallback
* @memberof module:ari-client
* @param {Error} err - error object if any, null otherwise
* @param {Client} ari - ARI client instance
*/
callback
) {
var client = new Client(baseUrl, user, pass);
client.setMaxListeners(0);
return client._attachApi().asCallback(callback);
};