UNPKG

monobank

Version:
451 lines (374 loc) 16.1 kB
'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;