Skip to content

Commit

Permalink
Add an HTTP client which uses fetch.
Browse files Browse the repository at this point in the history
  • Loading branch information
dcr-stripe committed Sep 8, 2021
1 parent 31fc49a commit 01b9ff2
Show file tree
Hide file tree
Showing 6 changed files with 368 additions and 159 deletions.
109 changes: 109 additions & 0 deletions lib/net/FetchHttpClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use strict';

const {HttpClient, HttpClientResponse} = require('./HttpClient');

/**
* HTTP client which uses a `fetch` function to issue requests. This fetch
* function is expected to be the Web Fetch API function or an equivalent (such
* as the function provided by the node-fetch package).
*/
class FetchHttpClient extends HttpClient {
constructor(fetchFn) {
super();
this._fetchFn = fetchFn;
}

/* eslint-disable class-methods-use-this */
/** @override. */
getClientName() {
return 'fetch';
}
/* eslint-enable class-methods-use-this */

makeRequest(
host,
port,
path,
method,
headers,
requestData,
protocol,
timeout
) {
const isInsecureConnection = protocol === 'http';

const url = new URL(
path,
`${isInsecureConnection ? 'http' : 'https'}://${host}`
);
url.port = port;

const fetchPromise = this._fetchFn(url.toString(), {
method,
headers,
body: requestData || undefined,
});

let pendingTimeoutId;
const timeoutPromise = new Promise((_, reject) => {
pendingTimeoutId = setTimeout(() => {
pendingTimeoutId = null;
reject(HttpClient.makeTimeoutError());
}, timeout);
});

return Promise.race([fetchPromise, timeoutPromise])
.then((res) => {
return new FetchHttpClientResponse(res);
})
.finally(() => {
if (pendingTimeoutId) {
clearTimeout(pendingTimeoutId);
}
});
}
}

class FetchHttpClientResponse extends HttpClientResponse {
constructor(res) {
super(
res.status,
FetchHttpClientResponse._transformHeadersToObject(res.headers)
);
this._res = res;
}

getRawResponse() {
return this._res;
}

toStream(streamCompleteCallback) {
// Unfortunately `fetch` does not have event handlers for when the stream is
// completely read. We therefore invoke the streamCompleteCallback right
// away. This callback emits a response event with metadata and completes
// metrics, so it's ok to do this without waiting for the stream to be
// completely read.
streamCompleteCallback();

// Fetch's `body` property is expected to be a readable stream of the body.
return this._res.body;
}

toJSON() {
return this._res.json();
}

static _transformHeadersToObject(headers) {
// Fetch uses a Headers instance so this must be converted to a barebones
// JS object to meet the HttpClient interface.
const headersObj = {};

for (const entry of headers) {
headersObj[entry[0]] = entry[1];
}

return headersObj;
}
}

module.exports = {FetchHttpClient, FetchHttpClientResponse};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"mocha": "^8.3.2",
"mocha-junit-reporter": "^1.23.1",
"nock": "^13.1.1",
"node-fetch": "^2.6.2",
"nyc": "^15.1.0",
"prettier": "^1.16.4",
"typescript": "^3.7.2"
Expand Down
61 changes: 61 additions & 0 deletions test/net/FetchHttpClient.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

const expect = require('chai').expect;
const fetch = require('node-fetch');
const {Readable} = require('stream');
const {FetchHttpClient} = require('../../lib/net/FetchHttpClient');

const createFetchHttpClient = () => {
return new FetchHttpClient(fetch);
};

const {createHttpClientTestSuite, ArrayReadable} = require('./helpers');

createHttpClientTestSuite(
'FetchHttpClient',
createFetchHttpClient,
(setupNock, sendRequest) => {
describe('raw stream', () => {
it('getRawResponse()', async () => {
setupNock().reply(200);
const response = await sendRequest();
expect(response.getRawResponse()).to.be.an.instanceOf(fetch.Response);
});

it('toStream returns the body as a stream', async () => {
setupNock().reply(200, () => new ArrayReadable(['hello, world!']));

const response = await sendRequest();

return new Promise((resolve) => {
const stream = response.toStream(() => true);

// node-fetch returns a Node Readable here. In a Web API context, this
// would be a Web ReadableStream.
expect(stream).to.be.an.instanceOf(Readable);

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) => {
response.toStream(() => {
resolve();
});
});
});
});
}
);
167 changes: 8 additions & 159 deletions test/net/NodeHttpClient.spec.js
Original file line number Diff line number Diff line change
@@ -1,142 +1,17 @@
'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',
});
});
const {createHttpClientTestSuite, ArrayReadable} = require('./helpers');

createHttpClientTestSuite(
'NodeHttpClient',
createNodeHttpClient,
(setupNock, sendRequest) => {
describe('raw stream', () => {
it('getRawResponse()', async () => {
setupNock().reply(200);

Expand Down Expand Up @@ -184,32 +59,6 @@ describe('NodeHttpClient', () => {
});
});
});

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');
}
});
});
});
});
}
);
Loading

0 comments on commit 01b9ff2

Please sign in to comment.