From 1668b048dbc672fa1a630dd14783a9880a879ab3 Mon Sep 17 00:00:00 2001 From: Parth Verma <75295512+parthverma1@users.noreply.github.com> Date: Mon, 29 Jul 2024 23:11:07 -0700 Subject: [PATCH] Added support for HTTP/2 request (#1427) --- README.md | 3 + docs/protocol-profile-behavior.md | 3 + lib/requester/core.js | 6 +- lib/requester/requester-pool.js | 1 + test/fixtures/servers/_servers.js | 38 +++- .../protocolVersion.test.js | 152 ++++++++++++++++ .../pm-send-request-cookies.test.js | 12 +- .../requester-spec/protocolVersion.test.js | 164 ++++++++++++++++++ 8 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 test/integration/protocol-profile-behavior/protocolVersion.test.js create mode 100644 test/integration/requester-spec/protocolVersion.test.js diff --git a/README.md b/README.md index cbd6ebb71..367b33cfc 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ runner.run(collection, { // Maximum allowed response size in bytes (only supported on Node, ignored in the browser) maxResponseSize: 1000000, + // HTTP Protocol version to use. Valid options are http1, http2, and auto (only supported on Node, ignored in the browser) + protocolVersion: 'http1', + // Enable to use WHATWG URL parser and encoder useWhatWGUrlParser: true, diff --git a/docs/protocol-profile-behavior.md b/docs/protocol-profile-behavior.md index 7cb2bb445..c4f5cb775 100644 --- a/docs/protocol-profile-behavior.md +++ b/docs/protocol-profile-behavior.md @@ -45,6 +45,9 @@ Redirect with the original HTTP method, by default redirects with HTTP method GE - `followAuthorizationHeader: Boolean`
Retain `authorization` header when a redirect happens to a different hostname. +- `protocolVersion: String`
+HTTP Protocol version to be used, supported values are 'http1', 'http2', and 'auto' + - `removeRefererHeaderOnRedirect: Boolean`
Removes the `referer` header when a redirect happens. diff --git a/lib/requester/core.js b/lib/requester/core.js index 48b7dc70d..8e9a18b85 100644 --- a/lib/requester/core.js +++ b/lib/requester/core.js @@ -82,7 +82,10 @@ var dns = require('dns'), // removes the `referer` header when a redirect happens (default: false) // @note `referer` header set in the initial request will be preserved during redirect chain - removeRefererHeader: 'removeRefererHeaderOnRedirect' + removeRefererHeader: 'removeRefererHeaderOnRedirect', + + // Select the HTTP protocol version to be used. Valid options are http1/http2/auto + protocolVersion: 'protocolVersion' }, /** @@ -399,6 +402,7 @@ module.exports = { * @param defaultOpts.followOriginalHttpMethod * @param defaultOpts.maxRedirects * @param defaultOpts.maxResponseSize + * @param defaultOpts.protocolVersion * @param defaultOpts.implicitCacheControl * @param defaultOpts.implicitTraceHeader * @param defaultOpts.removeRefererHeaderOnRedirect diff --git a/lib/requester/requester-pool.js b/lib/requester/requester-pool.js index d32a3c0b1..6954655ed 100644 --- a/lib/requester/requester-pool.js +++ b/lib/requester/requester-pool.js @@ -24,6 +24,7 @@ RequesterPool = function (options, callback) { cookieJar: _.get(options, 'requester.cookieJar'), // default set later in this constructor strictSSL: _.get(options, 'requester.strictSSL'), maxResponseSize: _.get(options, 'requester.maxResponseSize'), + protocolVersion: _.get(options, 'requester.protocolVersion'), // @todo drop support in v8 useWhatWGUrlParser: _.get(options, 'requester.useWhatWGUrlParser', false), insecureHTTPParser: _.get(options, 'requester.insecureHTTPParser'), diff --git a/test/fixtures/servers/_servers.js b/test/fixtures/servers/_servers.js index 816585f2f..7f5f6ad13 100644 --- a/test/fixtures/servers/_servers.js +++ b/test/fixtures/servers/_servers.js @@ -6,6 +6,7 @@ const fs = require('fs'), path = require('path'), http = require('http'), https = require('https'), + http2 = require('http2'), crypto = require('crypto'), GraphQL = require('graphql'), express = require('express'), @@ -684,6 +685,40 @@ function createDigestServer (options) { return app; } +function createHTTP2Server (opts) { + var server, + certDataPath = path.join(__dirname, '../certificates'), + options = { + key: path.join(certDataPath, 'server-key.pem'), + cert: path.join(certDataPath, 'server-crt.pem'), + ca: path.join(certDataPath, 'ca.pem') + }, + optionsWithFilePath = ['key', 'cert', 'ca', 'pfx']; + + if (opts) { + options = Object.assign(options, opts); + } + + optionsWithFilePath.forEach(function (option) { + if (!options[option]) { return; } + + options[option] = fs.readFileSync(options[option]); + }); + + server = http2.createSecureServer(options, function (req, res) { + server.emit(req.url, req, res); + }); + + server.on('listening', function () { + server.port = this.address().port; + server.url = 'https://localhost:' + server.port; + }); + + enableServerDestroy(server); + + return server; +} + module.exports = { createSSLServer, createHTTPServer, @@ -694,5 +729,6 @@ module.exports = { createEdgeGridAuthServer, createNTLMServer, createBytesServer, - createDigestServer + createDigestServer, + createHTTP2Server }; diff --git a/test/integration/protocol-profile-behavior/protocolVersion.test.js b/test/integration/protocol-profile-behavior/protocolVersion.test.js new file mode 100644 index 000000000..c9ff9d74d --- /dev/null +++ b/test/integration/protocol-profile-behavior/protocolVersion.test.js @@ -0,0 +1,152 @@ +var sinon = require('sinon'), + expect = require('chai').expect, + IS_NODE = typeof window === 'undefined', + IS_NODE_20 = IS_NODE && parseInt(process.versions.node.split('.')[0], 10) === 20, + server = IS_NODE && require('../../fixtures/servers/_servers'); + +(IS_NODE ? describe : describe.skip)('Requester Spec: protocolVersion', function () { + var protocolVersions = [undefined, 'http1', 'http2', 'auto'], + servers = { + http1: null, + http2: null + }, + requestHandler = function (req, res) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('okay'); + }, + forInAsync = function (obj, fn, cb) { + if (!(obj && fn)) { return; } + !cb && (cb = function () { /* (ಠ_ಠ) */ }); + + var index = 0, + keys = Object.keys(obj), + next = function (err) { + if (err || index >= keys.length) { + return cb(err); + } + + fn.call(obj, keys[index++], next); + }; + + if (!keys.length) { + return cb(); + } + + next(); + }; + + before(function (done) { + servers = { + http1: server.createSSLServer(), + http2: server.createHTTP2Server() + }; + + forInAsync(servers, function (protocol, next) { + servers[protocol].on('/test', requestHandler); + servers[protocol].listen(0, next); + }, done); + }); + + after(function (done) { + forInAsync(servers, function (protocol, next) { + servers[protocol].destroy(next); + }, done); + }); + + protocolVersions.forEach((protocolVersion) => { + describe('HTTP/1.1 Server with item protocolVersion ' + protocolVersion, function () { + var testrun; + + before(function (done) { + this.run({ + requester: { + strictSSL: false + }, + collection: { + item: [{ + request: { + url: 'https://localhost:' + servers.http1.port + '/test' + }, + protocolProfileBehavior: { + protocolVersion + } + }] + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.start); + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should use the requested protocol', function () { + var history = testrun.response.getCall(0).lastArg, + executionData = history.execution.data[0]; + + if (protocolVersion === 'http2') { + const error = testrun.response.getCall(0).firstArg; + + expect(error.code).to.eql(IS_NODE_20 ? 'ERR_HTTP2_STREAM_CANCEL' : 'ERR_HTTP2_ERROR'); + !IS_NODE_20 && expect(error.errno).to.eql(-505); + + return; + } + expect(executionData.response.httpVersion).to.eql('1.1'); + }); + }); + }); + + protocolVersions.forEach((protocolVersion) => { + describe('HTTP/2 Server with item protocolVersion ' + protocolVersion, function () { + var testrun; + + before(function (done) { + this.run({ + requester: { + strictSSL: false + }, + collection: { + item: [{ + request: { + url: 'https://localhost:' + servers.http2.port + '/test' + }, + protocolProfileBehavior: { + protocolVersion + } + }] + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.start); + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should use the requested protocol', function () { + var history = testrun.response.getCall(0).lastArg, + executionData = history.execution.data[0], + response = testrun.response.getCall(0).args[2]; + + if (protocolVersion === 'http1' || !protocolVersion) { + expect(executionData.response.httpVersion).to.eql('1.0'); + expect(response).to.have.property('code', 403); + + return; + } + expect(executionData.response.httpVersion).to.eql('2.0'); + }); + }); + }); +}); diff --git a/test/integration/request-flow/pm-send-request-cookies.test.js b/test/integration/request-flow/pm-send-request-cookies.test.js index 4b139a071..c40d8375c 100644 --- a/test/integration/request-flow/pm-send-request-cookies.test.js +++ b/test/integration/request-flow/pm-send-request-cookies.test.js @@ -194,8 +194,10 @@ var _ = require('lodash'), headers = historyOne.execution.data[1].request.headers; // cookies are set after the first response in redirect - expect(headers[headers.length - 1]).to.have.property('key', 'Cookie'); - expect(headers[headers.length - 1].value).to.not.include('foo=bar'); + const cookieHeaderIndex = headers.findIndex((header) => { return header.key === 'Cookie'; }); + + expect(cookieHeaderIndex).to.be.greaterThan(-1); + expect(headers[cookieHeaderIndex].value).to.not.include('foo=bar'); expect(resOne.json()).to.eql({ cookies: {} }); expect(testrun.request.secondCall.args[2].json()).to.eql({ cookies: { foo: 'bar' } }); @@ -491,8 +493,10 @@ var _ = require('lodash'), historyOne = testrun.response.firstCall.lastArg, headers = historyOne.execution.data[1].request.headers; - expect(headers[headers.length - 1]).to.have.property('key', 'Cookie'); - expect(headers[headers.length - 1].value).to.include('foo=bar;'); + const cookieHeaderIndex = headers.findIndex((header) => { return header.key === 'Cookie'; }); + + expect(cookieHeaderIndex).to.be.greaterThan(-1); + expect(headers[cookieHeaderIndex].value).to.include('foo=bar;'); expect(!_.includes(_.get(resOne, 'headers.reference.set-cookie.value', ''), 'foo=bar;')).to .be.true; diff --git a/test/integration/requester-spec/protocolVersion.test.js b/test/integration/requester-spec/protocolVersion.test.js new file mode 100644 index 000000000..628ffee0a --- /dev/null +++ b/test/integration/requester-spec/protocolVersion.test.js @@ -0,0 +1,164 @@ +var sinon = require('sinon'), + expect = require('chai').expect, + IS_NODE = typeof window === 'undefined', + IS_NODE_20 = IS_NODE && parseInt(process.versions.node.split('.')[0], 10) === 20, + server = IS_NODE && require('../../fixtures/servers/_servers'); + +(IS_NODE ? describe : describe.skip)('Requester Spec: protocolVersion', function () { + var protocolVersions = [undefined, 'http1', 'http2', 'auto'], + servers = { + http1: null, + http2: null + }, + requestHandler = function (req, res) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('okay'); + }, + forInAsync = function (obj, fn, cb) { + if (!(obj && fn)) { return; } + !cb && (cb = function () { /* (ಠ_ಠ) */ }); + + var index = 0, + keys = Object.keys(obj), + next = function (err) { + if (err || index >= keys.length) { + return cb(err); + } + + fn.call(obj, keys[index++], next); + }; + + if (!keys.length) { + return cb(); + } + + next(); + }; + + before(function (done) { + servers = { + http1: server.createSSLServer(), + http2: server.createHTTP2Server() + }; + + forInAsync(servers, function (protocol, next) { + servers[protocol].on('/test', requestHandler); + servers[protocol].listen(0, next); + }, done); + }); + + after(function (done) { + forInAsync(servers, function (protocol, next) { + servers[protocol].destroy(next); + }, done); + }); + + protocolVersions.forEach((protocolVersion) => { + protocolVersions.forEach((requesterProtocolVersion) => { + describe('HTTP/1.1 Server with item protocolVersion ' + protocolVersion + + ' and requesterProtocolVersion ' + requesterProtocolVersion, function () { + var testrun; + + before(function (done) { + this.run({ + requester: { + strictSSL: false, + protocolVersion: requesterProtocolVersion + }, + collection: { + item: [{ + request: { + url: 'https://localhost:' + servers.http1.port + '/test' + }, + protocolProfileBehavior: { + ...(protocolVersion ? { protocolVersion } : {}) + } + }] + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.start); + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should use the requested protocol', function () { + var history = testrun.response.getCall(0).lastArg, + executionData = history.execution.data[0]; + + if (protocolVersion === 'http2' || + (protocolVersion === undefined && requesterProtocolVersion === 'http2')) { + const error = testrun.response.getCall(0).firstArg; + + expect(error.code).to.eql(IS_NODE_20 ? 'ERR_HTTP2_STREAM_CANCEL' : 'ERR_HTTP2_ERROR'); + !IS_NODE_20 && expect(error.errno).to.eql(-505); + + return; + } + + expect(executionData.response.httpVersion).to.eql('1.1'); + }); + }); + }); + }); + + protocolVersions.forEach((protocolVersion) => { + protocolVersions.forEach((requesterProtocolVersion) => { + describe('HTTP/2.0 Server with item protocolVersion ' + protocolVersion + + ' and requesterProtocolVersion ' + requesterProtocolVersion, function () { + var testrun; + + before(function (done) { + this.run({ + requester: { + strictSSL: false, + protocolVersion: requesterProtocolVersion + }, + collection: { + item: [{ + request: { + url: 'https://localhost:' + servers.http2.port + '/test' + }, + protocolProfileBehavior: { + ...(protocolVersion ? { protocolVersion } : {}) + } + }] + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.start); + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should use the requested protocol', function () { + var history = testrun.response.getCall(0).lastArg, + executionData = history.execution.data[0], + response = testrun.response.getCall(0).args[2]; + + if (protocolVersion === 'http1' || + (!protocolVersion && requesterProtocolVersion === 'http1') || + (!protocolVersion && !requesterProtocolVersion)) { + expect(executionData.response.httpVersion).to.eql('1.0'); + expect(response).to.have.property('code', 403); + + return; + } + expect(executionData.response.httpVersion).to.eql('2.0'); + }); + }); + }); + }); +});