diff --git a/CHANGELOG.md b/CHANGELOG.md index b93a9c02ef..ac2e3537b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,15 @@ This document is formatted according to the principles of [Keep A CHANGELOG](htt Please visit [cucumber/CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CONTRIBUTING.md) for more info on how to contribute to Cucumber. ---- - ## [In GIT](https://github.com/cucumber/cucumber-ruby/compare/v4.0.0.rc.4...master) ### Added -* N/A +* Accept `--out URL` to POST results to a web server + If a URL is used as output, the output will be sent with a POST request. + This can be overridden by specifying e.g. `http-method=PUT` as a query parameter. + Other `http-` prefixed query parameters will be converted to request headers + (with the `http-` prefix stripped off). ### Changed @@ -28,7 +31,6 @@ Please visit [cucumber/CONTRIBUTING.md](https://github.com/cucumber/cucumber/blo * N/A - ## [4.0.0.rc.4](https://github.com/cucumber/cucumber-ruby/compare/v4.0.0.rc.3...4.0.0.rc.4) ### Added diff --git a/cucumber.gemspec b/cucumber.gemspec index 2c9cfb379c..15d298266b 100644 --- a/cucumber.gemspec +++ b/cucumber.gemspec @@ -35,6 +35,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'simplecov', '~> 0.17', '>= 0.17.0' s.add_development_dependency 'syntax', '~> 1.2', '>= 1.2.2' s.add_development_dependency 'test-unit', '~> 1.2', '>= 1.2.3' + s.add_development_dependency 'webrick', '~> 1.6', '>= 1.6.0' # For maintainer scripts s.add_development_dependency 'octokit', '~> 4.14', '>= 4.14.0' diff --git a/lib/cucumber/cli/options.rb b/lib/cucumber/cli/options.rb index ee95c4543d..cf9f0247bf 100644 --- a/lib/cucumber/cli/options.rb +++ b/lib/cucumber/cli/options.rb @@ -104,7 +104,7 @@ def parse!(args) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength add_option :formats, [*parse_formats(v), @out_stream] end opts.on('--init', *init_msg) { |_v| initialize_project } - opts.on('-o', '--out [FILE|DIR]', *out_msg) { |v| out_stream v } + opts.on('-o', '--out [FILE|DIR|URL]', *out_msg) { |v| out_stream v } opts.on('-t TAG_EXPRESSION', '--tags TAG_EXPRESSION', *tags_msg) { |v| add_tag v } opts.on('-n NAME', '--name NAME', *name_msg) { |v| add_option :name_regexps, /#{v}/ } opts.on('-e', '--exclude PATTERN', *exclude_msg) { |v| add_option :excludes, Regexp.new(v) } @@ -295,10 +295,15 @@ def tags_msg def out_msg [ - 'Write output to a file/directory instead of STDOUT. This option', + 'Write output to a file/directory/URL instead of STDOUT. This option', 'applies to the previously specified --format, or the', 'default format if no format is specified. Check the specific', - "formatter's docs to see whether to pass a file or a dir." + "formatter's docs to see whether to pass a file, dir or URL.", + "\n", + 'When using a URL, the output of the formatter will be sent as the HTTP request body.', + 'HTTP headers and request method can be set with http- prefixed query parameters,', + 'for example ?http-content-type=application/json&http-method=PUT.', + 'All http- prefixed query parameters will be removed from the sent query parameters.' ] end diff --git a/lib/cucumber/formatter/http_io.rb b/lib/cucumber/formatter/http_io.rb new file mode 100644 index 0000000000..6f0e6eeb95 --- /dev/null +++ b/lib/cucumber/formatter/http_io.rb @@ -0,0 +1,93 @@ +require 'net/http' + +module Cucumber + module Formatter + class HTTPIO + class << self + # Returns an IO that will write to a HTTP request's body + def open(url, https_verify_mode = nil) + @https_verify_mode = https_verify_mode + uri, method, headers = build_uri_method_headers(url) + + @req = build_request(uri, method, headers) + @http = build_client(uri, https_verify_mode) + + read_io, write_io = IO.pipe + @req.body_stream = read_io + + class << write_io + attr_writer :request_thread + + def start_request(http, req) + @req_thread = Thread.new do + begin + res = http.request(req) + raise StandardError, "request to #{req.uri} failed with status #{res.code}" if res.code.to_i >= 400 + rescue StandardError => e + @http_error = e + end + end + end + + def close + super + begin + @req_thread.join + rescue StandardError + nil + end + raise @http_error unless @http_error.nil? + end + end + write_io.start_request(@http, @req) + + write_io + end + + def build_uri_method_headers(url) + uri = URI(url) + query_pairs = uri.query ? URI.decode_www_form(uri.query) : [] + + # Build headers from query parameters prefixed with http- and extract HTTP method + http_query_pairs = query_pairs.select { |pair| pair[0] =~ /^http-/ } + http_query_hash_without_prefix = Hash[http_query_pairs.map do |pair| + [ + pair[0][5..-1].downcase, # remove http- prefix + pair[1] + ] + end] + method = http_query_hash_without_prefix.delete('method') || 'POST' + headers = { + 'transfer-encoding' => 'chunked' + }.merge(http_query_hash_without_prefix) + + # Update the query with the http-* parameters removed + remaining_query_pairs = query_pairs - http_query_pairs + new_query_hash = Hash[remaining_query_pairs] + uri.query = URI.encode_www_form(new_query_hash) unless new_query_hash.empty? + [uri, method, headers] + end + + private + + def build_request(uri, method, headers) + method_class_name = "#{method[0].upcase}#{method[1..-1].downcase}" + req = Net::HTTP.const_get(method_class_name).new(uri) + headers.each do |header, value| + req[header] = value + end + req + end + + def build_client(uri, https_verify_mode) + http = Net::HTTP.new(uri.hostname, uri.port) + if uri.scheme == 'https' + http.use_ssl = true + http.verify_mode = https_verify_mode if https_verify_mode + end + http + end + end + end + end +end diff --git a/lib/cucumber/formatter/io.rb b/lib/cucumber/formatter/io.rb index 3f9473088d..1693bfe8a0 100644 --- a/lib/cucumber/formatter/io.rb +++ b/lib/cucumber/formatter/io.rb @@ -1,21 +1,27 @@ # frozen_string_literal: true +require 'cucumber/formatter/http_io' + module Cucumber module Formatter module Io module_function - def ensure_io(path_or_io) - return nil if path_or_io.nil? - return path_or_io if path_or_io.respond_to?(:write) - file = File.open(path_or_io, Cucumber.file_mode('w')) + def ensure_io(path_or_url_or_io) + return nil if path_or_url_or_io.nil? + return path_or_url_or_io if path_or_url_or_io.respond_to?(:write) + io = if path_or_url_or_io.match(%r{^https?://}) + HTTPIO.open(path_or_url_or_io) + else + File.open(path_or_url_or_io, Cucumber.file_mode('w')) + end at_exit do - unless file.closed? - file.flush - file.close + unless io.closed? + io.flush + io.close end end - file + io end def ensure_file(path, name) diff --git a/spec/cucumber/formatter/http_io_spec.rb b/spec/cucumber/formatter/http_io_spec.rb new file mode 100644 index 0000000000..c359ea71de --- /dev/null +++ b/spec/cucumber/formatter/http_io_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'stringio' +require 'webrick' +require 'webrick/https' +require 'spec_helper' +require 'cucumber/formatter/io' + +module Cucumber + module Formatter + describe HTTPIO do + include Io + + def start_server(url) + uri = URI(url) + @received_body_io = StringIO.new + + rd, wt = IO.pipe + webrick_options = { + Port: uri.port, + Logger: WEBrick::Log.new(File.open(File::NULL, 'w')), + AccessLog: [], + StartCallback: proc do + wt.write(1) # write "1", signal a server start message + wt.close + end + } + if uri.scheme == 'https' + webrick_options[:SSLEnable] = true + # Set up a self-signed cert + webrick_options[:SSLCertName] = [%w[CN localhost]] + end + + @server = WEBrick::HTTPServer.new(webrick_options) + @server.mount_proc '/' do |req, _res| + IO.copy_stream(req.body_reader, @received_body_io) + end + @server.mount_proc '/404' do |_req, res| + res.status = 404 + end + + Thread.new do + @server.start + end + rd.read(1) # read a byte for the server start signal + rd.close + end + + context 'created by Io#ensure_io' do + it 'creates an IO that POSTs with HTTP' do + url = 'http://localhost:9987' + start_server(url) + sent_body = 'X' * 10_000_000 # 10Mb + + io = ensure_io(url) + io.write(sent_body) + io.flush + io.close + @received_body_io.rewind + received_body = @received_body_io.read + expect(received_body).to eq(sent_body) + end + + it 'streams HTTP body to server' do + url = 'http://localhost:9987' + start_server(url) + sent_body = 'X' * 10_000_000 + + io = ensure_io(url) + io.write(sent_body) + io.flush + sleep 0.2 # ugh + # Not calling io.close + @received_body_io.rewind + received_body = @received_body_io.read + expect(received_body.length).to be > 0 + end + + it 'notifies user if the server responds with error' do + url = 'http://localhost:9987/404' + start_server(url) + io = ensure_io(url) + expect { io.close }.to(raise_error('request to http://localhost:9987/404 failed with status 404')) + end + + it 'notifies user if the server is unreachable' do + url = 'http://localhost:9987' + io = ensure_io(url) + expect { io.close }.to(raise_error(/Failed to open TCP connection to localhost:9987/)) + end + end + + context 'created with constructor (because we need to relax SSL verification during testing)' do + it 'POSTs with HTTPS' do + url = 'https://localhost:9987' + start_server(url) + sent_body = 'X' * 10_000_000 # 10Mb + + io = HTTPIO.open(url, OpenSSL::SSL::VERIFY_NONE) + io.write(sent_body) + io.flush + io.close + @received_body_io.rewind + received_body = @received_body_io.read + expect(received_body).to eq(sent_body) + end + end + + it 'sets HTTP method when http-method is set' do + uri, method, = HTTPIO.build_uri_method_headers('http://localhost:9987?http-method=PUT&foo=bar') + expect(method).to eq('PUT') + expect(uri.to_s).to eq('http://localhost:9987?foo=bar') + end + + it 'sets Content-Type header when http-content-type query parameter set' do + uri, _method, headers = HTTPIO.build_uri_method_headers('http://localhost:9987?http-content-type=text/plain&foo=bar') + expect(headers['content-type']).to eq('text/plain') + expect(uri.to_s).to eq('http://localhost:9987?foo=bar') + end + + after do + @server&.shutdown + end + end + end +end