diff --git a/lib/StripeResource.js b/lib/StripeResource.js
index 472fca6749..e16bd90120 100644
--- a/lib/StripeResource.js
+++ b/lib/StripeResource.js
@@ -1,7 +1,5 @@
'use strict';
-const http = require('http');
-const https = require('https');
const path = require('path');
const utils = require('./utils');
@@ -14,8 +12,7 @@ const {
StripeAPIError,
} = require('./Error');
-const defaultHttpAgent = new http.Agent({keepAlive: true});
-const defaultHttpsAgent = new https.Agent({keepAlive: true});
+const HttpClient = require('./net/HttpClient');
// Provide extension mechanism for Stripe Resource Sub-Classes
StripeResource.extend = utils.protoExtend;
@@ -104,35 +101,39 @@ StripeResource.prototype = {
};
},
- _addHeadersDirectlyToResponse(res, headers) {
+ _addHeadersDirectlyToObject(obj, headers) {
// For convenience, make some headers easily accessible on
// lastResponse.
// NOTE: Stripe responds with lowercase header names/keys.
- res.requestId = headers['request-id'];
- res.stripeAccount = res.stripeAccount || headers['stripe-account'];
- res.apiVersion = res.apiVersion || headers['stripe-version'];
- res.idempotencyKey = res.idempotencyKey || headers['idempotency-key'];
+ obj.requestId = headers['request-id'];
+ obj.stripeAccount = obj.stripeAccount || headers['stripe-account'];
+ obj.apiVersion = obj.apiVersion || headers['stripe-version'];
+ obj.idempotencyKey = obj.idempotencyKey || headers['idempotency-key'];
},
- _makeResponseEvent(req, res, headers) {
+ _makeResponseEvent(requestEvent, statusCode, headers) {
const requestEndTime = Date.now();
- const requestDurationMs = requestEndTime - req._requestStart;
+ const requestDurationMs = requestEndTime - requestEvent.request_start_time;
return utils.removeNullish({
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,
+ method: requestEvent.method,
+ path: requestEvent.path,
+ status: statusCode,
+ request_id: this._getRequestId(headers),
elapsed: requestDurationMs,
- request_start_time: req._requestStart,
+ request_start_time: requestEvent.request_start_time,
request_end_time: requestEndTime,
});
},
+ _getRequestId(headers) {
+ return headers['request-id'];
+ },
+
/**
* Used by methods with spec.streaming === true. For these methods, we do not
* buffer successful responses into memory or do parse them into stripe
@@ -143,17 +144,31 @@ StripeResource.prototype = {
* still be buffered/parsed and handled by _jsonResponseHandler -- see
* makeRequest)
*/
- _streamingResponseHandler(req, callback) {
+ _streamingResponseHandler(requestEvent, callback) {
return (res) => {
- const headers = res.headers || {};
- this._addHeadersDirectlyToResponse(res, headers);
+ const headers = res.getHeaders();
- res.once('end', () => {
- const responseEvent = this._makeResponseEvent(req, res, headers);
+ const streamCompleteCallback = () => {
+ const responseEvent = this._makeResponseEvent(
+ requestEvent,
+ res.getStatusCode(),
+ headers
+ );
this._stripe._emitter.emit('response', responseEvent);
- this._recordRequestMetrics(res.requestId, responseEvent.elapsed);
- });
- return callback(null, res);
+ this._recordRequestMetrics(
+ this._getRequestId(headers),
+ responseEvent.elapsed
+ );
+ };
+
+ const stream = res.toStream(streamCompleteCallback);
+
+ // This is here for backwards compatibility, as the stream is a raw
+ // HTTP response in Node and the legacy behavior was to mutate this
+ // response.
+ this._addHeadersDirectlyToObject(stream, headers);
+
+ return callback(null, stream);
};
},
@@ -162,73 +177,79 @@ StripeResource.prototype = {
* parses the JSON and returns it (i.e. passes it to the callback) if there
* is no "error" field. Otherwise constructs/passes an appropriate Error.
*/
- _jsonResponseHandler(req, callback) {
+ _jsonResponseHandler(requestEvent, callback) {
return (res) => {
- let response = '';
-
- res.setEncoding('utf8');
- res.on('data', (chunk) => {
- response += chunk;
- });
- res.once('end', () => {
- const headers = res.headers || {};
- this._addHeadersDirectlyToResponse(res, headers);
- const responseEvent = this._makeResponseEvent(req, res, headers);
- 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,
- };
+ const headers = res.getHeaders();
+ const requestId = this._getRequestId(headers);
+ const statusCode = res.getStatusCode();
+
+ const responseEvent = this._makeResponseEvent(
+ requestEvent,
+ statusCode,
+ headers
+ );
+ this._stripe._emitter.emit('response', responseEvent);
+
+ res
+ .toJSON()
+ .then(
+ (jsonResponse) => {
+ if (jsonResponse.error) {
+ let err;
+
+ // Convert OAuth error responses into a standard format
+ // so that the rest of the error logic can be shared
+ if (typeof jsonResponse.error === 'string') {
+ jsonResponse.error = {
+ type: jsonResponse.error,
+ message: jsonResponse.error_description,
+ };
+ }
+
+ jsonResponse.error.headers = headers;
+ jsonResponse.error.statusCode = statusCode;
+ jsonResponse.error.requestId = requestId;
+
+ if (statusCode === 401) {
+ err = new StripeAuthenticationError(jsonResponse.error);
+ } else if (statusCode === 403) {
+ err = new StripePermissionError(jsonResponse.error);
+ } else if (statusCode === 429) {
+ err = new StripeRateLimitError(jsonResponse.error);
+ } else {
+ err = StripeError.generate(jsonResponse.error);
+ }
+
+ throw err;
}
- response.error.headers = headers;
- response.error.statusCode = res.statusCode;
- response.error.requestId = res.requestId;
-
- if (res.statusCode === 401) {
- err = new StripeAuthenticationError(response.error);
- } else if (res.statusCode === 403) {
- err = new StripePermissionError(response.error);
- } else if (res.statusCode === 429) {
- err = new StripeRateLimitError(response.error);
- } else {
- err = StripeError.generate(response.error);
- }
- return callback.call(this, err, null);
- }
- } catch (e) {
- return callback.call(
- this,
- new StripeAPIError({
+ return jsonResponse;
+ },
+ (e) => {
+ throw new StripeAPIError({
message: 'Invalid JSON received from the Stripe API',
- response,
exception: e,
requestId: headers['request-id'],
- }),
- null
- );
- }
-
- this._recordRequestMetrics(res.requestId, responseEvent.elapsed);
-
- // Expose res object
- Object.defineProperty(response, 'lastResponse', {
- enumerable: false,
- writable: false,
- value: res,
- });
- callback.call(this, null, response);
- });
+ });
+ }
+ )
+ .then(
+ (jsonResponse) => {
+ this._recordRequestMetrics(requestId, responseEvent.elapsed);
+
+ // Expose raw response object.
+ const rawResponse = res.getRawResponse();
+ this._addHeadersDirectlyToObject(rawResponse, headers);
+ Object.defineProperty(jsonResponse, 'lastResponse', {
+ enumerable: false,
+ writable: false,
+ value: rawResponse,
+ });
+
+ callback.call(this, null, jsonResponse);
+ },
+ (e) => callback.call(this, e, null)
+ );
};
},
@@ -265,15 +286,15 @@ StripeResource.prototype = {
// The API may ask us not to retry (e.g., if doing so would be a no-op)
// or advise us to retry (e.g., in cases of lock timeouts); we defer to that.
- if (res.headers && res.headers['stripe-should-retry'] === 'false') {
+ if (res.getHeaders()['stripe-should-retry'] === 'false') {
return false;
}
- if (res.headers && res.headers['stripe-should-retry'] === 'true') {
+ if (res.getHeaders()['stripe-should-retry'] === 'true') {
return true;
}
// Retry on conflict errors.
- if (res.statusCode === 409) {
+ if (res.getStatusCode() === 409) {
return true;
}
@@ -282,7 +303,7 @@ StripeResource.prototype = {
// Note that we expect the stripe-should-retry header to be false
// in most cases when a 500 is returned, since our idempotency framework
// would typically replay it anyway.
- if (res.statusCode >= 500) {
+ if (res.getStatusCode() >= 500) {
return true;
}
@@ -434,22 +455,18 @@ StripeResource.prototype = {
? options.settings.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',
- });
+ const req = this._stripe
+ .getApiField('httpClient')
+ .makeRequest(
+ host || this._stripe.getApiField('host'),
+ this._stripe.getApiField('port'),
+ path,
+ method,
+ headers,
+ requestData,
+ this._stripe.getApiField('protocol'),
+ timeout
+ );
const requestStartTime = Date.now();
@@ -466,77 +483,48 @@ StripeResource.prototype = {
const maxRetries = this._getMaxNetworkRetries(options.settings);
- req._requestEvent = requestEvent;
-
- req._requestStart = requestStartTime;
-
this._stripe._emitter.emit('request', requestEvent);
- req.setTimeout(timeout, this._timeoutHandler(timeout, req, callback));
-
- req.once('response', (res) => {
- if (this._shouldRetry(res, requestRetries, maxRetries)) {
- return retryRequest(
- makeRequest,
- apiVersion,
- headers,
- requestRetries,
- ((res || {}).headers || {})['retry-after']
- );
- } else {
- if (options.streaming && res.statusCode < 400) {
- return this._streamingResponseHandler(req, callback)(res);
+ req
+ .then((res) => {
+ if (this._shouldRetry(res, requestRetries, maxRetries)) {
+ return retryRequest(
+ makeRequest,
+ apiVersion,
+ headers,
+ requestRetries,
+ res.getHeaders()['retry-after']
+ );
+ } else if (options.streaming && res.getStatusCode() < 400) {
+ return this._streamingResponseHandler(requestEvent, callback)(res);
+ } else {
+ return this._jsonResponseHandler(requestEvent, callback)(res);
}
- return this._jsonResponseHandler(req, callback)(res);
- }
- });
+ })
+ .catch((error) => {
+ if (this._shouldRetry(null, requestRetries, maxRetries)) {
+ return retryRequest(
+ makeRequest,
+ apiVersion,
+ headers,
+ requestRetries,
+ null
+ );
+ } else {
+ const isTimeoutError =
+ error.code && error.code === HttpClient.TIMEOUT_ERROR_CODE;
- req.on('error', (error) => {
- if (this._shouldRetry(null, requestRetries, maxRetries)) {
- return retryRequest(
- makeRequest,
- apiVersion,
- headers,
- requestRetries,
- null
- );
- } else {
- if (error.code === 'ETIMEDOUT') {
return callback.call(
this,
new StripeConnectionError({
- message: `Request aborted due to timeout being reached (${timeout}ms)`,
+ message: isTimeoutError
+ ? `Request aborted due to timeout being reached (${timeout}ms)`
+ : this._generateConnectionErrorMessage(requestRetries),
detail: error,
})
);
}
- return callback.call(
- this,
- new StripeConnectionError({
- message: this._generateConnectionErrorMessage(requestRetries),
- detail: error,
- }),
- null
- );
- }
- });
-
- req.once('socket', (socket) => {
- if (socket.connecting) {
- socket.once(
- isInsecureConnection ? 'connect' : 'secureConnect',
- () => {
- // Send payload; we're safe:
- req.write(requestData);
- req.end();
- }
- );
- } else {
- // we're already connected
- req.write(requestData);
- req.end();
- }
- });
+ });
};
const prepareAndMakeRequest = (error, data) => {
diff --git a/lib/net/HttpClient.js b/lib/net/HttpClient.js
new file mode 100644
index 0000000000..b4c758929f
--- /dev/null
+++ b/lib/net/HttpClient.js
@@ -0,0 +1,60 @@
+'use strict';
+
+/* eslint-disable class-methods-use-this */
+
+/**
+ * Encapsulates the logic for issuing a request to the Stripe API. This is an
+ * experimental interface and is not yet stable.
+ */
+class HttpClient {
+ makeRequest(
+ host,
+ port,
+ path,
+ method,
+ headers,
+ requestData,
+ protocol,
+ timeout
+ ) {
+ throw new Error('makeRequest not implemented.');
+ }
+
+ /** Helper to make a consistent timeout error across implementations. */
+ static makeTimeoutError() {
+ const timeoutErr = new TypeError(HttpClient.TIMEOUT_ERROR_CODE);
+ timeoutErr.code = HttpClient.TIMEOUT_ERROR_CODE;
+ return timeoutErr;
+ }
+}
+
+HttpClient.TIMEOUT_ERROR_CODE = 'ETIMEDOUT';
+
+class HttpClientResponse {
+ constructor(statusCode, headers) {
+ this._statusCode = statusCode;
+ this._headers = headers;
+ }
+
+ getStatusCode() {
+ return this._statusCode;
+ }
+
+ getHeaders() {
+ return this._headers;
+ }
+
+ getRawResponse() {
+ throw new Error('getRawResponse not implemented.');
+ }
+
+ toStream(streamCompleteCallback) {
+ throw new Error('toStream not implemented.');
+ }
+
+ toJSON() {
+ throw new Error('toJSON not implemented.');
+ }
+}
+
+module.exports = {HttpClient, HttpClientResponse};
diff --git a/lib/net/NodeHttpClient.js b/lib/net/NodeHttpClient.js
new file mode 100644
index 0000000000..da45ab9cf0
--- /dev/null
+++ b/lib/net/NodeHttpClient.js
@@ -0,0 +1,120 @@
+'use strict';
+
+const http = require('http');
+const https = require('https');
+
+const {HttpClient, HttpClientResponse} = require('./HttpClient');
+
+const defaultHttpAgent = new http.Agent({keepAlive: true});
+const defaultHttpsAgent = new https.Agent({keepAlive: true});
+
+/**
+ * HTTP client which uses the Node `http` and `https` packages to issue
+ * requests.`
+ */
+class NodeHttpClient extends HttpClient {
+ constructor(agent) {
+ super();
+ this._agent = agent;
+ }
+
+ makeRequest(
+ host,
+ port,
+ path,
+ method,
+ headers,
+ requestData,
+ protocol,
+ timeout
+ ) {
+ const isInsecureConnection = protocol === 'http';
+
+ let agent = this._agent;
+ if (!agent) {
+ agent = isInsecureConnection ? defaultHttpAgent : defaultHttpsAgent;
+ }
+
+ const requestPromise = new Promise((resolve, reject) => {
+ const req = (isInsecureConnection ? http : https).request({
+ host: host,
+ port: port,
+ path,
+ method,
+ agent,
+ headers,
+ ciphers: 'DEFAULT:!aNULL:!eNULL:!LOW:!EXPORT:!SSLv2:!MD5',
+ });
+
+ req.setTimeout(timeout, () => {
+ req.destroy(HttpClient.makeTimeoutError());
+ });
+
+ req.on('response', (res) => {
+ resolve(new NodeHttpClientResponse(res));
+ });
+
+ req.on('error', (error) => {
+ reject(error);
+ });
+
+ req.once('socket', (socket) => {
+ if (socket.connecting) {
+ socket.once(
+ isInsecureConnection ? 'connect' : 'secureConnect',
+ () => {
+ // Send payload; we're safe:
+ req.write(requestData);
+ req.end();
+ }
+ );
+ } else {
+ // we're already connected
+ req.write(requestData);
+ req.end();
+ }
+ });
+ });
+
+ return requestPromise;
+ }
+}
+
+class NodeHttpClientResponse extends HttpClientResponse {
+ constructor(res) {
+ super(res.statusCode, res.headers || {});
+ this._res = res;
+ }
+
+ getRawResponse() {
+ return this._res;
+ }
+
+ toStream(streamCompleteCallback) {
+ // The raw response is itself the stream, so we just return that. To be
+ // backwards comaptible, we should invoke the streamCompleteCallback only
+ // once the stream has been fully consumed.
+ this._res.once('end', () => streamCompleteCallback());
+ return this._res;
+ }
+
+ toJSON() {
+ return new Promise((resolve, reject) => {
+ let response = '';
+
+ this._res.setEncoding('utf8');
+ this._res.on('data', (chunk) => {
+ response += chunk;
+ });
+ this._res.once('end', () => {
+ try {
+ resolve(JSON.parse(response));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+ }
+}
+
+module.exports = {NodeHttpClient, NodeHttpClientResponse};
diff --git a/lib/stripe.js b/lib/stripe.js
index ab83c37950..14751435aa 100644
--- a/lib/stripe.js
+++ b/lib/stripe.js
@@ -33,6 +33,7 @@ const ALLOWED_CONFIG_PROPERTIES = [
'typescript',
'maxNetworkRetries',
'httpAgent',
+ 'httpClient',
'timeout',
'host',
'port',
@@ -49,6 +50,10 @@ const {emitWarning} = utils;
Stripe.StripeResource = require('./StripeResource');
Stripe.resources = resources;
+const {HttpClient, HttpClientResponse} = require('./net/HttpClient');
+Stripe.HttpClient = HttpClient;
+Stripe.HttpClientResponse = HttpClientResponse;
+
function Stripe(key, config = {}) {
if (!(this instanceof Stripe)) {
return new Stripe(key, config);
@@ -79,6 +84,8 @@ function Stripe(key, config = {}) {
);
}
+ const agent = props.httpAgent || null;
+
this._api = {
auth: null,
host: props.host || DEFAULT_HOST,
@@ -92,7 +99,8 @@ function Stripe(key, config = {}) {
props.maxNetworkRetries,
0
),
- agent: props.httpAgent || null,
+ agent: agent,
+ httpClient: props.httpClient || Stripe.createNodeHttpClient(agent),
dev: false,
stripeAccount: props.stripeAccount || null,
};
@@ -126,6 +134,11 @@ function Stripe(key, config = {}) {
Stripe.errors = require('./Error');
Stripe.webhooks = require('./Webhooks');
+Stripe.createNodeHttpClient = (agent) => {
+ const {NodeHttpClient} = require('./net/NodeHttpClient');
+ return new NodeHttpClient(agent);
+};
+
Stripe.prototype = {
/**
* @deprecated will be removed in a future major version. Use the config object instead:
diff --git a/test/StripeResource.spec.js b/test/StripeResource.spec.js
index 2f5ec6ccf9..f3d4600ad3 100644
--- a/test/StripeResource.spec.js
+++ b/test/StripeResource.spec.js
@@ -1,16 +1,25 @@
'use strict';
const utils = require('../testUtils');
-
const nock = require('nock');
const stripe = require('../testUtils').getSpyableStripe();
const expect = require('chai').expect;
const testUtils = require('../testUtils');
+const {HttpClientResponse} = require('../lib/net/HttpClient');
const StripeResource = require('../lib/StripeResource');
const stripeMethod = StripeResource.method;
+const {
+ StripeAuthenticationError,
+ StripeIdempotencyError,
+ StripePermissionError,
+ StripeRateLimitError,
+ StripeError,
+ StripeConnectionError,
+} = require('../lib/Error');
+
describe('StripeResource', () => {
describe('createResourcePathWithSymbols', () => {
it('Generates a path', () => {
@@ -208,6 +217,119 @@ describe('StripeResource', () => {
);
});
+ it('throws an error on invalid JSON', (done) => {
+ return utils.getTestServerStripe(
+ {},
+ (req, res) => {
+ // Write back JSON to close out the server.
+ res.write('invalidjson{}');
+ res.end();
+ },
+ (err, stripe, closeServer) => {
+ if (err) {
+ return done(err);
+ }
+ stripe.charges.create(options.data, (err, result) => {
+ expect(err.message).to.deep.equal(
+ 'Invalid JSON received from the Stripe API'
+ );
+ closeServer();
+ done();
+ });
+ }
+ );
+ });
+ it('throws an valid headers but connection error', (done) => {
+ return utils.getTestServerStripe(
+ {},
+ (req, res) => {
+ // Send out valid headers and a partial response. We then interrupt
+ // the response with an error.
+ res.writeHead(200);
+ res.write('{"ab');
+ res.destroy(new Error('something happened'));
+ },
+ (err, stripe, closeServer) => {
+ if (err) {
+ return done(err);
+ }
+ stripe.charges.create(options.data, (err, result) => {
+ expect(err).to.be.an.instanceOf(StripeConnectionError);
+ done();
+ });
+ }
+ );
+ });
+
+ it('throws a StripeAuthenticationError on 401', (done) => {
+ nock(`https://${options.host}`)
+ .post(options.path, options.params)
+ .reply(401, {
+ error: {
+ message: 'message',
+ },
+ });
+
+ realStripe.charges.create(options.data, (err) => {
+ expect(err).to.be.an.instanceOf(StripeAuthenticationError);
+ expect(err.message).to.be.equal('message');
+ done();
+ });
+ });
+
+ it('throws a StripePermissionError on 403', (done) => {
+ nock(`https://${options.host}`)
+ .post(options.path, options.params)
+ .reply(403, {
+ error: {
+ message: 'message',
+ },
+ });
+
+ realStripe.charges.create(options.data, (err) => {
+ expect(err).to.be.an.instanceOf(StripePermissionError);
+ expect(err.message).to.be.equal('message');
+ done();
+ });
+ });
+
+ it('throws a StripeRateLimitError on 429', (done) => {
+ nock(`https://${options.host}`)
+ .post(options.path, options.params)
+ .reply(429, {
+ error: {
+ message: 'message',
+ },
+ });
+
+ realStripe.charges.create(options.data, (err) => {
+ expect(err).to.be.an.instanceOf(StripeRateLimitError);
+ expect(err.message).to.be.equal('message');
+ done();
+ });
+ });
+
+ it('throws a StripeError based on the underlying error type', (done) => {
+ const error = {
+ type: 'idempotency_error',
+ };
+
+ expect(StripeError.generate(error)).to.be.an.instanceOf(
+ StripeIdempotencyError
+ );
+
+ nock(`https://${options.host}`)
+ .post(options.path, options.params)
+ .reply(400, {
+ error,
+ });
+
+ realStripe.charges.create(options.data, (err) => {
+ expect(err).to.be.an.instanceOf(StripeIdempotencyError);
+ done();
+ });
+ });
+
it('retries connection timeout errors', (done) => {
let nRequestsReceived = 0;
return utils.getTestServerStripe(
@@ -557,27 +679,92 @@ describe('StripeResource', () => {
}
);
});
+
+ it('invokes the callback with successful results', (done) => {
+ const returnedCharge = {
+ id: 'ch_123',
+ };
+ return utils.getTestServerStripe(
+ {},
+ (req, res) => {
+ res.write(JSON.stringify(returnedCharge));
+ res.end();
+ },
+ (err, stripe, closeServer) => {
+ if (err) {
+ return done(err);
+ }
+ stripe.charges.create(options.data, (err, result) => {
+ expect(result).to.deep.equal(returnedCharge);
+ closeServer();
+ done();
+ });
+ }
+ );
+ });
+
+ it('returns successful results to await', (done) => {
+ const returnedCharge = {
+ id: 'ch_123',
+ };
+ return utils.getTestServerStripe(
+ {},
+ (req, res) => {
+ res.write(JSON.stringify(returnedCharge));
+ res.end();
+ },
+ async (err, stripe, closeServer) => {
+ if (err) {
+ return done(err);
+ }
+ try {
+ const result = await stripe.charges.create(options.data);
+ expect(result).to.deep.equal(returnedCharge);
+ closeServer();
+ done();
+ } catch (err) {
+ done(err);
+ }
+ }
+ );
+ });
});
describe('_shouldRetry', () => {
it("should return false if we've reached maximum retries", () => {
- const res = stripe.invoices._shouldRetry({statusCode: 409}, 1, 1);
+ const res = stripe.invoices._shouldRetry(
+ new HttpClientResponse(409, {}),
+ 1,
+ 1
+ );
expect(res).to.equal(false);
});
it('should return true if we have more retries available', () => {
- const res = stripe.invoices._shouldRetry({statusCode: 409}, 0, 1);
+ const res = stripe.invoices._shouldRetry(
+ new HttpClientResponse(409, {}),
+ 0,
+ 1
+ );
expect(res).to.equal(true);
});
it('should return true if the error code is either 409 or 503', () => {
- let res = stripe.invoices._shouldRetry({statusCode: 409}, 0, 1);
+ let res = stripe.invoices._shouldRetry(
+ new HttpClientResponse(409, {}),
+ 0,
+ 1
+ );
expect(res).to.equal(true);
- res = stripe.invoices._shouldRetry({statusCode: 503}, 0, 1);
+ res = stripe.invoices._shouldRetry(
+ new HttpClientResponse(503, {}),
+ 0,
+ 1
+ );
expect(res).to.equal(true);
});
@@ -585,10 +772,7 @@ describe('StripeResource', () => {
it('should return false if the status is 200', () => {
// mocking that we're on our 2nd request
const res = stripe.invoices._shouldRetry(
- {
- statusCode: 200,
- req: {_requestEvent: {method: 'POST'}},
- },
+ new HttpClientResponse(200, {}),
1,
2
);
diff --git a/test/net/NodeHttpClient.spec.js b/test/net/NodeHttpClient.spec.js
new file mode 100644
index 0000000000..84855ee036
--- /dev/null
+++ b/test/net/NodeHttpClient.spec.js
@@ -0,0 +1,215 @@
+'use strict';
+
+const {Readable} = require('stream');
+
+const http = require('http');
+const nock = require('nock');
+const expect = require('chai').expect;
+// const {fail} = require('chai').assert;
+
+const {createNodeHttpClient} = require('../../lib/Stripe');
+const utils = require('../../lib/utils');
+const {fail} = require('assert');
+
+/**
+ * Readable stream which will emit a data event for each value in the array
+ * passed. Readable.from accomplishes this beyond Node 10.17.
+ */
+class ArrayReadable extends Readable {
+ constructor(values) {
+ super();
+ this._index = 0;
+ this._values = values;
+ }
+
+ _read() {
+ if (this._index === this._values.length) {
+ // Destroy the stream once we've read all values.
+ this.push(null);
+ } else {
+ this.push(Buffer.from(this._values[this._index], 'utf8'));
+ this._index += 1;
+ }
+ }
+}
+
+describe('NodeHttpClient', () => {
+ const setupNock = () => {
+ return nock('http://stripe.com').get('/test');
+ };
+
+ const sendRequest = (options) => {
+ options = options || {};
+ return createNodeHttpClient().makeRequest(
+ 'stripe.com',
+ options.port || 80,
+ '/test',
+ options.method || 'GET',
+ options.headers || {},
+ options.requestData,
+ 'http',
+ options.timeout || 1000
+ );
+ };
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ describe('makeRequest', () => {
+ it('rejects with a timeout error', async () => {
+ setupNock()
+ .delayConnection(31)
+ .reply(200, 'hello, world!');
+
+ try {
+ await sendRequest({timeout: 30});
+ fail();
+ } catch (e) {
+ expect(e.code).to.be.equal('ETIMEDOUT');
+ }
+ });
+
+ it('forwards any error', async () => {
+ setupNock().replyWithError('sample error');
+
+ try {
+ await sendRequest();
+ fail();
+ } catch (e) {
+ expect(e.message).to.be.equal('sample error');
+ }
+ });
+
+ it('sends request headers', async () => {
+ nock('http://stripe.com', {
+ reqheaders: {
+ sample: 'value',
+ },
+ })
+ .get('/test')
+ .reply(200);
+
+ await sendRequest({headers: {sample: 'value'}});
+ });
+
+ it('sends request data (POST)', (done) => {
+ const expectedData = utils.stringifyRequestData({id: 'test'});
+
+ nock('http://stripe.com')
+ .post('/test')
+ .reply(200, (uri, requestBody) => {
+ expect(requestBody).to.equal(expectedData);
+ done();
+ });
+
+ sendRequest({method: 'POST', requestData: expectedData});
+ });
+
+ it('custom port', async () => {
+ nock('http://stripe.com:1234')
+ .get('/test')
+ .reply(200);
+ await sendRequest({port: 1234});
+ });
+
+ describe('NodeHttpClientResponse', () => {
+ it('getStatusCode()', async () => {
+ setupNock().reply(418, 'hello, world!');
+
+ const response = await sendRequest();
+
+ expect(response.getStatusCode()).to.be.equal(418);
+ });
+
+ it('getHeaders()', async () => {
+ setupNock().reply(200, 'hello, world!', {
+ 'X-Header-1': '123',
+ 'X-Header-2': 'test',
+ });
+
+ const response = await sendRequest();
+
+ // Headers get transformed into lower case.
+ expect(response.getHeaders()).to.be.deep.equal({
+ 'x-header-1': '123',
+ 'x-header-2': 'test',
+ });
+ });
+
+ it('getRawResponse()', async () => {
+ setupNock().reply(200);
+
+ const response = await sendRequest();
+
+ expect(response.getRawResponse()).to.be.an.instanceOf(
+ http.IncomingMessage
+ );
+ });
+
+ it('toStream returns a readable stream', async () => {
+ setupNock().reply(200, () => new ArrayReadable(['hello, world!']));
+
+ const response = await sendRequest();
+
+ return new Promise((resolve) => {
+ const stream = response.toStream(() => true);
+
+ let streamedContent = '';
+ stream.on('data', (chunk) => {
+ streamedContent += chunk;
+ });
+ stream.on('end', () => {
+ expect(streamedContent).to.equal('hello, world!');
+ resolve();
+ });
+ });
+ });
+
+ it('toStream invokes the streamCompleteCallback', async () => {
+ setupNock().reply(200, () => new ArrayReadable(['hello, world!']));
+
+ const response = await sendRequest();
+
+ return new Promise((resolve) => {
+ let streamedContent = '';
+
+ const stream = response.toStream(() => {
+ expect(streamedContent).to.equal('hello, world!');
+ resolve();
+ });
+
+ stream.on('data', (chunk) => {
+ streamedContent += chunk;
+ });
+ });
+ });
+
+ it('toJSON accumulates all data chunks in utf-8 encoding', async () => {
+ setupNock().reply(
+ 200,
+ () => new ArrayReadable(['{"a', 'bc":', '"∑ 123', '"}'])
+ );
+
+ const response = await sendRequest();
+
+ const json = await response.toJSON();
+
+ expect(json).to.deep.equal({abc: '∑ 123'});
+ });
+
+ it('toJSON throws when JSON parsing fails', async () => {
+ setupNock().reply(200, '{"a');
+
+ const response = await sendRequest();
+
+ try {
+ await response.toJSON();
+ fail();
+ } catch (e) {
+ expect(e.message).to.be.equal('Unexpected end of JSON input');
+ }
+ });
+ });
+ });
+});
diff --git a/test/stripe.spec.js b/test/stripe.spec.js
index 215c8ae1e7..366a461416 100644
--- a/test/stripe.spec.js
+++ b/test/stripe.spec.js
@@ -484,7 +484,7 @@ describe('Stripe Module', function() {
let headers;
let stripeClient;
let closeServer;
- before((callback) => {
+ beforeEach((callback) => {
testUtils.getTestServerStripe(
{
stripeAccount: 'my_stripe_account',
@@ -505,7 +505,7 @@ describe('Stripe Module', function() {
}
);
});
- after(() => closeServer());
+ afterEach(() => closeServer());
it('is respected', (callback) => {
stripeClient.customers.create((err) => {
closeServer();
diff --git a/testUtils/index.js b/testUtils/index.js
index e230262334..145fc2f4d0 100644
--- a/testUtils/index.js
+++ b/testUtils/index.js
@@ -10,6 +10,8 @@ const http = require('http');
const ResourceNamespace = require('../lib/ResourceNamespace').ResourceNamespace;
+const testingHttpAgent = new http.Agent({keepAlive: false});
+
const utils = (module.exports = {
getTestServerStripe: (clientOptions, handler, callback) => {
const server = http.createServer((req, res) => {
@@ -28,6 +30,7 @@ const utils = (module.exports = {
host: 'localhost',
port,
protocol: 'http',
+ httpAgent: testingHttpAgent,
...clientOptions,
}
);
diff --git a/types/2020-08-27/index.d.ts b/types/2020-08-27/index.d.ts
index 5c7f8f1594..94dd90936e 100644
--- a/types/2020-08-27/index.d.ts
+++ b/types/2020-08-27/index.d.ts
@@ -1,6 +1,7 @@
// File generated from our OpenAPI spec
///
+///
///
///
///
diff --git a/types/lib.d.ts b/types/lib.d.ts
index 4154564225..53d11355aa 100644
--- a/types/lib.d.ts
+++ b/types/lib.d.ts
@@ -45,6 +45,7 @@ declare module 'stripe' {
}
export type LatestApiVersion = '2020-08-27';
export type HttpAgent = Agent;
+ export type HttpProtocol = 'http' | 'https';
export interface StripeConfig {
/**
@@ -80,6 +81,13 @@ declare module 'stripe' {
*/
httpAgent?: HttpAgent;
+ /**
+ * Use a custom http client, rather than relying on Node libraries.
+ * Useful for making requests in contexts other than NodeJS (eg. using
+ * `fetch`).
+ */
+ httpClient?: HttpClient;
+
/**
* Request timeout in milliseconds.
* The default is 80000
@@ -90,7 +98,7 @@ declare module 'stripe' {
port?: string | number;
- protocol?: 'https' | 'http';
+ protocol?: HttpProtocol;
/**
* Pass `telemetry: false` to disable headers that provide Stripe
diff --git a/types/net/net.d.ts b/types/net/net.d.ts
new file mode 100644
index 0000000000..f17af0ca16
--- /dev/null
+++ b/types/net/net.d.ts
@@ -0,0 +1,62 @@
+///
+
+import {IncomingMessage} from 'http';
+declare module 'stripe' {
+ namespace Stripe {
+ /**
+ * Encapsulates the logic for issuing a request to the Stripe API. This is
+ * an experimental interface and is not yet stable.
+ */
+ export interface HttpClient<
+ ResponseType extends HttpClientResponse = HttpClientResponse
+ > {
+ makeRequest(
+ host: string,
+ port: string | number,
+ path: string,
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
+ headers: object,
+ requestData: string | null,
+ protocol: Stripe.HttpProtocol,
+ timeout: number
+ ): Promise;
+ }
+
+ /**
+ * Abstract representation of an HTTP response. This is an experimental
+ * interface and is not yet stable.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ interface HttpClientResponse {
+ /** The numeric HTTP status code for the response. */
+ getStatusCode(): number;
+
+ /** The response headers. */
+ getHeaders(): {[key: string]: string};
+
+ /** This returns the underlying raw response object for the client. */
+ getRawResponse(): RawResponseType;
+
+ /**
+ * This returns the content as a stream. The expectation is that content
+ * will not have been buffered into memory at this point by the client.
+ *
+ * The streamCompleteCallback should be invoked by the response
+ * implementation when the stream has been consumed.
+ */
+ toStream(streamCompleteCallback: () => void): StreamType;
+
+ /**
+ * Converts the response content into a JSON object, failing if JSON
+ * couldn't be parsed.
+ */
+ toJSON(): Promise