monobank
Version:
Monobank API wrapper
451 lines (374 loc) • 16.1 kB
JavaScript
'use strict';
const http = require('http');
const https = require('https');
const path = require('path');
const uuid = require('uuid/v4');
const utils = require('./utils');
const Error = require('./Error');
const defaultHttpAgent = new http.Agent({keepAlive: true});
const defaultHttpsAgent = new https.Agent({keepAlive: true});
// Provide extension mechanism for Monobank Resource Sub-Classes
MonobankResource.extend = utils.protoExtend;
// Expose method-creator & prepared (basic) methods
MonobankResource.method = require('./MonobankMethod');
MonobankResource.BASIC_METHODS = require('./MonobankMethod.basic');
MonobankResource.MAX_BUFFERED_REQUEST_METRICS = 100;
/**
* Encapsulates request logic for a Monobank Resource
*/
function MonobankResource(stripe, deprecatedUrlData) {
this._stripe = stripe;
if (deprecatedUrlData) {
throw new Error(
'Support for curried url params was dropped in stripe-node v7.0.0. Instead, pass two ids.'
);
}
this.basePath = utils.makeURLInterpolator(
this.basePath || stripe.getApiField('basePath')
);
this.resourcePath = this.path;
this.path = utils.makeURLInterpolator(this.path);
if (this.includeBasic) {
this.includeBasic.forEach(function(methodName) {
this[methodName] = MonobankResource.BASIC_METHODS[methodName];
}, this);
}
this.initialize(...arguments);
}
MonobankResource.prototype = {
path: '',
// Methods that don't use the API's default '/v1' path can override it with this setting.
basePath: null,
initialize() {},
// Function to override the default data processor. This allows full control
// over how a MonobankResource's request data will get converted into an HTTP
// body. This is useful for non-standard HTTP requests. The function should
// take method name, data, and headers as arguments.
requestDataProcessor: null,
// Function to add a validation checks before sending the request, errors should
// be thrown, and they will be passed to the callback/promise.
validateRequest: null,
createFullPath(commandPath, urlData) {
return path
.join(
this.basePath(urlData),
this.path(urlData),
typeof commandPath == 'function' ? commandPath(urlData) : commandPath
)
.replace(/\\/g, '/'); // ugly workaround for Windows
},
// Creates a relative resource path with symbols left in (unlike
// createFullPath which takes some data to replace them with). For example it
// might produce: /invoices/{id}
createResourcePathWithSymbols(pathWithSymbols) {
return `/${path
.join(this.resourcePath, pathWithSymbols || '')
.replace(/\\/g, '/')}`; // ugly workaround for Windows
},
// DEPRECATED: Here for backcompat in case users relied on this.
wrapTimeout: utils.callbackifyPromiseWithTimeout,
_timeoutHandler(timeout, req, callback) {
return () => {
const timeoutErr = new Error('ETIMEDOUT');
timeoutErr.code = 'ETIMEDOUT';
req._isAborted = true;
req.abort();
callback.call(
this,
new Error.MonobankConnectionError({
message: `Request aborted due to timeout being reached (${timeout}ms)`,
detail: timeoutErr,
}),
null
);
};
},
_responseHandler(req, callback) {
return (res) => {
let response = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
response += chunk;
});
res.on('end', () => {
const headers = res.headers || {};
// NOTE: Monobank responds with lowercase header names/keys.
// For convenience, make Request-Id easily accessible on
// lastResponse.
res.requestId = headers['request-id'];
const requestDurationMs = Date.now() - req._requestStart;
const responseEvent = utils.removeEmpty({
api_version: headers['stripe-version'],
account: headers['stripe-account'],
idempotency_key: headers['idempotency-key'],
method: req._requestEvent.method,
path: req._requestEvent.path,
status: res.statusCode,
request_id: res.requestId,
elapsed: requestDurationMs,
});
this._stripe._emitter.emit('response', responseEvent);
try {
response = JSON.parse(response);
if (response.error) {
let err;
// Convert OAuth error responses into a standard format
// so that the rest of the error logic can be shared
if (typeof response.error === 'string') {
response.error = {
type: response.error,
message: response.error_description,
};
}
response.error.headers = headers;
response.error.statusCode = res.statusCode;
response.error.requestId = res.requestId;
if (res.statusCode === 401) {
err = new Error.MonobankAuthenticationError(response.error);
} else if (res.statusCode === 403) {
err = new Error.MonobankPermissionError(response.error);
} else if (res.statusCode === 429) {
err = new Error.MonobankRateLimitError(response.error);
} else {
err = Error.MonobankError.generate(response.error);
}
return callback.call(this, err, null);
}
} catch (e) {
return callback.call(
this,
new Error.MonobankAPIError({
message: 'Invalid JSON received from the Monobank API',
response,
exception: e,
requestId: headers['request-id'],
}),
null
);
}
this._recordRequestMetrics(res.requestId, requestDurationMs);
// Expose res object
Object.defineProperty(response, 'lastResponse', {
enumerable: false,
writable: false,
value: res,
});
callback.call(this, null, response);
});
};
},
_generateConnectionErrorMessage(requestRetries) {
return `An error occurred with our connection to Monobank.${
requestRetries > 0 ? ` Request was retried ${requestRetries} times.` : ''
}`;
},
_errorHandler(req, requestRetries, callback) {
return (error) => {
if (req._isAborted) {
// already handled
return;
}
callback.call(
this,
new Error.MonobankConnectionError({
message: this._generateConnectionErrorMessage(requestRetries),
detail: error,
}),
null
);
};
},
_shouldRetry(res, numRetries) {
// Do not retry if we are out of retries.
if (numRetries >= this._stripe.getMaxNetworkRetries()) {
return false;
}
// Retry on connection error.
if (!res) {
return true;
}
// Retry on conflict and availability errors.
if (res.statusCode === 409 || res.statusCode === 503) {
return true;
}
// Retry on 5xx's, except POST's, which our idempotency framework
// would just replay as 500's again anyway.
if (res.statusCode >= 500 && res.req._requestEvent.method !== 'POST') {
return true;
}
return false;
},
_getSleepTimeInMS(numRetries) {
const initialNetworkRetryDelay = this._stripe.getInitialNetworkRetryDelay();
const maxNetworkRetryDelay = this._stripe.getMaxNetworkRetryDelay();
// Apply exponential backoff with initialNetworkRetryDelay on the
// number of numRetries so far as inputs. Do not allow the number to exceed
// maxNetworkRetryDelay.
let sleepSeconds = Math.min(
initialNetworkRetryDelay * Math.pow(numRetries - 1, 2),
maxNetworkRetryDelay
);
// Apply some jitter by randomizing the value in the range of
// (sleepSeconds / 2) to (sleepSeconds).
sleepSeconds *= 0.5 * (1 + Math.random());
// But never sleep less than the base sleep seconds.
sleepSeconds = Math.max(initialNetworkRetryDelay, sleepSeconds);
return sleepSeconds * 1000;
},
_defaultHeaders(auth, contentLength, apiVersion) {
let userAgentString = `Monobank/v1 NodeBindings/${this._stripe.getConstant(
'PACKAGE_VERSION'
)}`;
if (this._stripe._appInfo) {
userAgentString += ` ${this._stripe.getAppInfoAsString()}`;
}
const headers = {
// Use specified auth token or use default from this stripe instance:
Authorization: auth ? `Bearer ${auth}` : this._stripe.getApiField('auth'),
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': contentLength,
'User-Agent': userAgentString,
};
if (apiVersion) {
headers['Monobank-Version'] = apiVersion;
}
return headers;
},
_addTelemetryHeader(headers) {
if (
this._stripe.getTelemetryEnabled() &&
this._stripe._prevRequestMetrics.length > 0
) {
const metrics = this._stripe._prevRequestMetrics.shift();
headers['X-Monobank-Client-Telemetry'] = JSON.stringify({
last_request_metrics: metrics,
});
}
},
_recordRequestMetrics(requestId, requestDurationMs) {
if (this._stripe.getTelemetryEnabled() && requestId) {
if (
this._stripe._prevRequestMetrics.length >
MonobankResource.MAX_BUFFERED_REQUEST_METRICS
) {
utils.emitWarning(
'Request metrics buffer is full, dropping telemetry message.'
);
} else {
this._stripe._prevRequestMetrics.push({
request_id: requestId,
request_duration_ms: requestDurationMs,
});
}
}
},
_request(method, host, path, data, auth, options, callback) {
let requestData;
const makeRequest = (apiVersion, headers, numRetries) => {
const timeout = this._stripe.getApiField('timeout');
const isInsecureConnection =
this._stripe.getApiField('protocol') == 'http';
let agent = this._stripe.getApiField('agent');
if (agent == null) {
agent = isInsecureConnection ? defaultHttpAgent : defaultHttpsAgent;
}
const req = (isInsecureConnection ? http : https).request({
host: host || this._stripe.getApiField('host'),
port: this._stripe.getApiField('port'),
path,
method,
agent,
headers,
ciphers: 'DEFAULT:!aNULL:!eNULL:!LOW:!EXPORT:!SSLv2:!MD5',
});
// If this is a POST and we allow multiple retries, set a idempotency key if one is not
// already provided.
if (method === 'POST' && this._stripe.getMaxNetworkRetries() > 0) {
if (!headers.hasOwnProperty('Idempotency-Key')) {
headers['Idempotency-Key'] = uuid();
}
}
const requestEvent = utils.removeEmpty({
api_version: apiVersion,
account: headers['Monobank-Account'],
idempotency_key: headers['Idempotency-Key'],
method,
path,
});
const requestRetries = numRetries || 0;
req._requestEvent = requestEvent;
req._requestStart = Date.now();
this._stripe._emitter.emit('request', requestEvent);
req.setTimeout(timeout, this._timeoutHandler(timeout, req, callback));
req.on('response', (res) => {
if (this._shouldRetry(res, requestRetries)) {
return retryRequest(makeRequest, apiVersion, headers, requestRetries);
} else {
return this._responseHandler(req, callback)(res);
}
});
req.on('error', (error) => {
if (this._shouldRetry(null, requestRetries)) {
return retryRequest(makeRequest, apiVersion, headers, requestRetries);
} else {
return this._errorHandler(req, requestRetries, callback)(error);
}
});
req.on('socket', (socket) => {
if (socket.connecting) {
socket.on(isInsecureConnection ? 'connect' : 'secureConnect', () => {
// Send payload; we're safe:
req.write(requestData);
req.end();
});
} else {
// we're already connected
req.write(requestData);
req.end();
}
});
};
const makeRequestWithData = (error, data) => {
if (error) {
return callback(error);
}
const apiVersion = this._stripe.getApiField('version');
requestData = data;
const headers = this._defaultHeaders(
auth,
requestData.length,
apiVersion
);
this._stripe.getClientUserAgent((cua) => {
headers['X-Monobank-Client-User-Agent'] = cua;
if (options.headers) {
Object.assign(headers, options.headers);
}
this._addTelemetryHeader(headers);
makeRequest(apiVersion, headers);
});
};
if (this.requestDataProcessor) {
this.requestDataProcessor(
method,
data,
options.headers,
makeRequestWithData
);
} else {
makeRequestWithData(null, utils.stringifyRequestData(data || {}));
}
const retryRequest = (requestFn, apiVersion, headers, requestRetries) => {
requestRetries += 1;
return setTimeout(
requestFn,
this._getSleepTimeInMS(requestRetries),
apiVersion,
headers,
requestRetries
);
};
},
};
module.exports = MonobankResource;