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