Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support --out http://.... #1395

Merged
merged 24 commits into from
Mar 13, 2020
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions lib/cucumber/cli/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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 raw request body',
'HTTP headers can be set with http- prefixed query parameters,',
'for example ?http-content-type=application/json. These parameters will be',
'removed from the sent query parameters.'
]
end

Expand Down
91 changes: 91 additions & 0 deletions lib/cucumber/formatter/http_io.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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)
if res.code.to_i >= 400
raise Exception.new("request to #{req.uri} failed with status #{res.code}")
aslakhellesoy marked this conversation as resolved.
Show resolved Hide resolved
end
rescue Exception => err
@http_error = err
end
end
end

def close
super
@req_thread.join rescue nil
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
22 changes: 14 additions & 8 deletions lib/cucumber/formatter/io.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
111 changes: 111 additions & 0 deletions spec/cucumber/formatter/http_io_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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.new {
wt.write(1) # write "1", signal a server start message
wt.close
}
}
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 '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