diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fa7186f..bca8d96 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,16 @@ { "name": "twurl dev container", - "dockerFile": "Dockerfile", - "context": "..", + "dockerComposeFile": "./docker-compose.yml", + "service": "twurl", "workspaceFolder": "/usr/src/app", "settings": { - "terminal.integrated.shell.linux": "/bin/bash" + "terminal.integrated.profiles.linux": { + "bash": { + "path": "bash", + "args": ["-l"] + } + } }, - "shutdownAction": "none", "extensions": [ "rebornix.Ruby" ] diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..652a66a --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3" +services: + twurl: + build: + context: .. + dockerfile: ./.devcontainer/Dockerfile + command: /bin/sh -c "while sleep 1000; do :; done" + volumes: + - ../:/usr/src/app diff --git a/lib/twurl/aliases_controller.rb b/lib/twurl/aliases_controller.rb index 6d9ebc0..4dd6b96 100644 --- a/lib/twurl/aliases_controller.rb +++ b/lib/twurl/aliases_controller.rb @@ -15,9 +15,13 @@ def dispatch end when 1 if options.path - OAuthClient.rcfile.alias(options.subcommands.first, options.path) + if Twurl::CLI::SUPPORTED_COMMANDS.include?(options.subcommands.first) + raise Exception, "ERROR: '#{options.subcommands.first}' is reserved for commands. Please use different alias name." + else + OAuthClient.rcfile.alias(options.subcommands.first, options.path) + end else - CLI.puts NO_PATH_PROVIDED_MESSAGE + raise Exception, NO_PATH_PROVIDED_MESSAGE end end end diff --git a/lib/twurl/app_only_oauth_client.rb b/lib/twurl/app_only_oauth_client.rb index a84b5c0..bd3133f 100644 --- a/lib/twurl/app_only_oauth_client.rb +++ b/lib/twurl/app_only_oauth_client.rb @@ -57,12 +57,9 @@ def set_http_client_options(http) http.set_debug_output(Twurl.options.debug_output_io) if Twurl.options.trace http.read_timeout = http.open_timeout = Twurl.options.timeout || 60 http.open_timeout = Twurl.options.connection_timeout if Twurl.options.connection_timeout - # Only override if Net::HTTP support max_retries (since Ruby >= 2.5) - http.max_retries = 0 if http.respond_to?(:max_retries=) - if Twurl.options.ssl? - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE - end + http.max_retries = 0 + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE http end diff --git a/lib/twurl/cli.rb b/lib/twurl/cli.rb index 60b01db..ad26a0a 100644 --- a/lib/twurl/cli.rb +++ b/lib/twurl/cli.rb @@ -6,8 +6,6 @@ class CLI PROTOCOL_PATTERN = /^\w+:\/\// README = File.dirname(__FILE__) + '/../../README.md' @output ||= STDOUT - class NoPathFound < Exception - end class << self attr_accessor :output @@ -15,8 +13,8 @@ class << self def run(args) begin options = parse_options(args) - rescue NoPathFound => e - exit + rescue Twurl::Exception => exception + abort(exception.message) end dispatch(options) end @@ -80,7 +78,6 @@ def parse_options(args) headers host quiet - disable_ssl request_method help version @@ -98,11 +95,11 @@ def parse_options(args) begin arguments = option_parser.parse!(args) rescue OptionParser::InvalidOption - CLI.puts "ERROR: undefined option" - exit + raise Exception "ERROR: undefined option" + rescue Twurl::Exception + raise rescue - CLI.puts "ERROR: invalid argument" - exit + raise Exception "ERROR: invalid argument" end Twurl.options.command = extract_command!(arguments) Twurl.options.path = extract_path!(arguments) @@ -110,7 +107,7 @@ def parse_options(args) if Twurl.options.command == DEFAULT_COMMAND and Twurl.options.path.nil? and Twurl.options.args.empty? CLI.puts option_parser - raise NoPathFound, "No path found" + raise Exception, "No path found" end Twurl.options @@ -172,7 +169,7 @@ def extract_path!(arguments) def escape_params(params) CGI::parse(params).map do |key, value| - "#{CGI.escape key}=#{CGI.escape value.first}" + "#{CGI.escape(key)}=#{CGI.escape(value.first)}" end.join("&") end end @@ -234,12 +231,12 @@ def trace def data on('-d', '--data [data]', 'Sends the specified data in a POST request to the HTTP server.') do |data| - if options.args.count { |item| /content-type: (.*)/i.match(item) } > 0 - options.data[data] = nil + if options.args.count { |item| /^content-type:\s+application\/json/i.match(item) } > 0 + options.json_data = true + options.data = data else - data.split('&').each do |pair| - key, value = pair.split('=', 2) - options.data[key] = value + CGI.parse(data).each_pair do |key, value| + options.data[key] = value.first end end end @@ -247,9 +244,13 @@ def data def raw_data on('-r', '--raw-data [data]', 'Sends the specified data as it is in a POST request to the HTTP server.') do |data| - CGI::parse(data).each_pair do |key, value| - options.data[key] = value.first + if options.raw_data + raise Exception, "ERROR: can't specify '-r' option more than once" + elsif options.args.include?('-d') || options.args.include?('--data') + raise Exception, "ERROR: can't use '-r' and '-d' options together" end + options.raw_data = true + options.data = data end end @@ -277,12 +278,6 @@ def quiet end end - def disable_ssl - on('-U', '--no-ssl', 'Disable SSL (default: SSL is enabled)') do |use_ssl| - options.protocol = 'http' - end - end - def request_method on('-X', '--request-method [method]', 'Request method (default: GET)') do |request_method| options.request_method = request_method.downcase @@ -298,7 +293,7 @@ def help def version on_tail("-v", "--version", "Show version") do - CLI.puts Version + CLI.puts "twurl version: #{Version}\nplatform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})" exit end end @@ -374,10 +369,6 @@ def base_url "#{protocol}://#{host}" end - def ssl? - protocol == 'https' - end - def debug_output_io super || STDERR end diff --git a/lib/twurl/oauth_client.rb b/lib/twurl/oauth_client.rb index 482f90c..c87226b 100644 --- a/lib/twurl/oauth_client.rb +++ b/lib/twurl/oauth_client.rb @@ -162,16 +162,20 @@ def build_request_from_options(options, &block) request.body = multipart_body.join request.content_type = "multipart/form-data, boundary=\"#{boundary}\"" - elsif request.content_type && options.data - request.body = options.data.keys.first + elsif options.json_data + request.body = options.data elsif options.data - request.content_type = "application/x-www-form-urlencoded" - if options.data.length == 1 && options.data.values.first == nil - request.body = options.data.keys.first + request.content_type = "application/x-www-form-urlencoded" unless request.content_type + if options.raw_data + request.body = options.data else - request.body = options.data.map do |key, value| - "#{key}=#{CGI.escape value}" - end.join("&") + begin + request.body = options.data.map do |key, value| + "#{key}" + (value.nil? ? "" : "=#{CGI.escape(value)}") + end.join("&") + rescue + raise Exception, "ERROR: failed to parse POST request body" + end end end request @@ -202,7 +206,11 @@ def exchange_credentials_for_access_token def perform_pin_authorize_workflow @request_token = consumer.get_request_token CLI.puts("Go to #{generate_authorize_url} and paste in the supplied PIN") - pin = STDIN.gets + begin + pin = STDIN.gets.chomp + rescue SystemExit, Interrupt + raise Exception, "Operation cancelled" + end access_token = @request_token.get_access_token(:oauth_verifier => pin.chomp) {:oauth_token => access_token.token, :oauth_token_secret => access_token.secret} end @@ -212,7 +220,7 @@ def generate_authorize_url params = request['Authorization'].sub(/^OAuth\s+/, '').split(/,\s+/).map { |p| k, v = p.split('=') v =~ /"(.*?)"/ - "#{k}=#{CGI::escape($1)}" + "#{k}=#{CGI.escape($1)}" }.join('&') "#{Twurl.options.base_url}#{request.path}?#{params}" end @@ -260,12 +268,9 @@ def configure_http! consumer.http.set_debug_output(Twurl.options.debug_output_io) if Twurl.options.trace consumer.http.read_timeout = consumer.http.open_timeout = Twurl.options.timeout || 60 consumer.http.open_timeout = Twurl.options.connection_timeout if Twurl.options.connection_timeout - # Only override if Net::HTTP support max_retries (since Ruby >= 2.5) - consumer.http.max_retries = 0 if consumer.http.respond_to?(:max_retries=) - if Twurl.options.ssl? - consumer.http.use_ssl = true - consumer.http.verify_mode = OpenSSL::SSL::VERIFY_NONE - end + consumer.http.max_retries = 0 + consumer.http.use_ssl = true + consumer.http.verify_mode = OpenSSL::SSL::VERIFY_NONE end def consumer diff --git a/lib/twurl/request_controller.rb b/lib/twurl/request_controller.rb index cc81f13..b3e9c70 100644 --- a/lib/twurl/request_controller.rb +++ b/lib/twurl/request_controller.rb @@ -22,11 +22,11 @@ def perform_request } } rescue URI::InvalidURIError - CLI.puts INVALID_URI_MESSAGE + raise Exception, INVALID_URI_MESSAGE rescue Net::ReadTimeout - CLI.puts READ_TIMEOUT_MESSAGE + raise Exception, READ_TIMEOUT_MESSAGE rescue Net::OpenTimeout - CLI.puts OPEN_TIMEOUT_MESSAGE + raise Exception, OPEN_TIMEOUT_MESSAGE end end diff --git a/test/alias_controller_test.rb b/test/alias_controller_test.rb index 3dda3a1..f896e6d 100644 --- a/test/alias_controller_test.rb +++ b/test/alias_controller_test.rb @@ -41,13 +41,15 @@ def test_when_alias_and_value_are_provided_they_are_added controller.dispatch end - def test_when_no_path_is_provided_nothing_happens + def test_error_if_no_path_is_provided options.subcommands = ['a'] assert_nil options.path - mock(Twurl::CLI).puts(Twurl::AliasesController::NO_PATH_PROVIDED_MESSAGE).times(1) + e = assert_raises Twurl::Exception do + controller = Twurl::AliasesController.new(client, options) + controller.dispatch + end - controller = Twurl::AliasesController.new(client, options) - controller.dispatch + assert_equal Twurl::AliasesController::NO_PATH_PROVIDED_MESSAGE, e.message end end diff --git a/test/cli_options_test.rb b/test/cli_options_test.rb index 53c3aa8..5db43ec 100644 --- a/test/cli_options_test.rb +++ b/test/cli_options_test.rb @@ -7,17 +7,8 @@ def setup end def test_base_url_is_built_from_protocol_and_host - options.protocol = 'http' - options.host = 'api.twitter.com' + options = Twurl::CLI.parse_options(['-H', 'ads-api.twitter.com']) - assert_equal 'http://api.twitter.com', options.base_url - end - - def test_ssl_is_enabled_if_the_protocol_is_https - options.protocol = 'http' - assert !options.ssl? - - options.protocol = 'https' - assert options.ssl? + assert_equal 'https://ads-api.twitter.com', options.base_url end end diff --git a/test/cli_test.rb b/test/cli_test.rb index 7d6fcb3..34830c6 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -26,9 +26,10 @@ def test_unsupported_command_specified_sets_default_command module PathParsingTests def test_missing_path_throws_no_path_found stub(Twurl::CLI).puts - assert_raises Twurl::CLI::NoPathFound do + e = assert_raises Twurl::Exception do Twurl::CLI.parse_options([]) end + assert_equal 'No path found', e.message end def test_uri_params_are_encoded @@ -124,17 +125,12 @@ def test_extracting_an_empty_key_value_pair include DataParsingTests module RawDataParsingTests - def test_extracting_a_single_key_value_pair - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value']) - assert_equal({'key' => 'value'}, options.data) + def test_raw_data_option_should_not_use_parser + options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=foo%26bar']) + assert_equal('key=foo%26bar', options.data) - options = Twurl::CLI.parse_options([TEST_PATH, '--raw-data', 'key=value']) - assert_equal({'key' => 'value'}, options.data) - end - - def test_with_special_to_url_characters_in_value - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=a+%26%26+b+%2B%2B+c']) - assert_equal({'key' => 'a && b ++ c'}, options.data) + options = Twurl::CLI.parse_options([TEST_PATH, '--raw-data', 'key=foo%26bar']) + assert_equal('key=foo%26bar', options.data) end def test_passing_data_and_no_explicit_request_method_defaults_request_method_to_post @@ -144,26 +140,19 @@ def test_passing_data_and_no_explicit_request_method_defaults_request_method_to_ def test_passing_data_and_an_explicit_request_method_uses_the_specified_method options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value', '-X', 'DELETE']) - assert_equal({'key' => 'value'}, options.data) assert_equal 'delete', options.request_method end - def test_multiple_pairs_when_option_is_specified_multiple_times_on_command_line_collects_all - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value', '-d', 'another=pair']) - assert_equal({'key' => 'value', 'another' => 'pair'}, options.data) - end - - def test_multiple_pairs_separated_by_ampersand_are_all_captured - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value+%26+value&another=pair']) - assert_equal({'key' => 'value & value', 'another' => 'pair'}, options.data) + def test_error_when_option_is_specified_multiple_times + assert_raises Twurl::Exception do + Twurl::CLI.parse_options([TEST_PATH, '-r', 'key1=value1', '-r', 'key2=value2']) + end end - def test_extracting_an_empty_key_value_pair - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=']) - assert_equal({'key' => ''}, options.data) - - options = Twurl::CLI.parse_options([TEST_PATH, '--raw-data', 'key=']) - assert_equal({'key' => ''}, options.data) + def test_error_when_option_is_specified_with_data_option + assert_raises Twurl::Exception do + Twurl::CLI.parse_options([TEST_PATH, '-r', 'key1=value1', '-d', 'key2=value2']) + end end end include RawDataParsingTests @@ -184,21 +173,6 @@ def test_multiple_headers_when_option_is_specified_multiple_times_on_command_lin end include HeaderParsingTests - module SSLDisablingTests - def test_ssl_is_on_by_default - options = Twurl::CLI.parse_options([TEST_PATH]) - assert options.ssl? - end - - def test_passing_no_ssl_option_disables_ssl - ['-U', '--no-ssl'].each do |switch| - options = Twurl::CLI.parse_options([TEST_PATH, switch]) - assert !options.ssl? - end - end - end - include SSLDisablingTests - module HostOptionTests def test_not_specifying_host_sets_it_to_the_default options = Twurl::CLI.parse_options([TEST_PATH]) diff --git a/test/oauth_client_test.rb b/test/oauth_client_test.rb index 6d86c05..84bf1ab 100644 --- a/test/oauth_client_test.rb +++ b/test/oauth_client_test.rb @@ -1,6 +1,8 @@ require File.dirname(__FILE__) + '/test_helper' class Twurl::OAuthClient::AbstractOAuthClientTest < Minitest::Test + TEST_PATH = '/1.1/url/does/not/matter.json' + attr_reader :client, :options def setup Twurl::OAuthClient.instance_variable_set(:@rcfile, nil) @@ -199,10 +201,7 @@ def test_request_is_made_using_request_method_and_path_and_data_in_options def test_content_type_is_not_overridden_if_set_and_data_in_options client = Twurl::OAuthClient.test_exemplar - - options.request_method = 'post' - options.data = { '{ "foo": "bar" }' => nil } - options.headers = { 'Content-Type' => 'application/json' } + options = Twurl::CLI.parse_options([TEST_PATH, '-d', '{ "foo": "bar" }', '-A', 'Content-Type: application/json']) mock(client.consumer.http).request( satisfy { |req| req.is_a?(Net::HTTP::Post) && req.content_type == 'application/json' } @@ -213,9 +212,7 @@ def test_content_type_is_not_overridden_if_set_and_data_in_options def test_content_type_is_set_to_form_encoded_if_not_set_and_data_in_options client = Twurl::OAuthClient.test_exemplar - - options.request_method = 'post' - options.data = { '{ "foo": "bar" }' => nil } + options = Twurl::CLI.parse_options([TEST_PATH, '-d', 'foo=bar']) mock(client.consumer.http).request( satisfy { |req| req.is_a?(Net::HTTP::Post) && req.content_type == 'application/x-www-form-urlencoded' } @@ -224,6 +221,21 @@ def test_content_type_is_set_to_form_encoded_if_not_set_and_data_in_options client.perform_request_from_options(options) end + def test_post_body_is_parsed_and_escaped_properly_through_request_builder + client = Twurl::OAuthClient.test_exemplar + options = Twurl::CLI.parse_options([TEST_PATH, '-d', 'foo=text%26text']) + + mock(client.consumer.http).request( + satisfy { |req| + req.is_a?(Net::HTTP::Post) && + req.content_type == 'application/x-www-form-urlencoded' && + req.body == 'foo=text%26text' + } + ) + + client.perform_request_from_options(options) + end + def test_user_agent_request_header_is_set client = Twurl::OAuthClient.test_exemplar expected_ua_string = "twurl version: #{Twurl::Version} platform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})" diff --git a/test/request_controller_test.rb b/test/request_controller_test.rb index 0133005..9720575 100644 --- a/test/request_controller_test.rb +++ b/test/request_controller_test.rb @@ -69,9 +69,12 @@ def test_request_response_is_json_formatted end def test_invalid_or_unspecified_urls_report_error - mock(Twurl::CLI).puts(Twurl::RequestController::INVALID_URI_MESSAGE).times(1) mock(client).perform_request_from_options(options).times(1) { raise URI::InvalidURIError } - controller.perform_request + e = assert_raises Twurl::Exception do + controller.perform_request + end + + assert_equal Twurl::RequestController::INVALID_URI_MESSAGE, e.message end end