diff --git a/lib/telebugs.rb b/lib/telebugs.rb index eb002ae..e85b038 100644 --- a/lib/telebugs.rb +++ b/lib/telebugs.rb @@ -1,24 +1,30 @@ # frozen_string_literal: true require "concurrent" +require "net/https" +require "json" require_relative "telebugs/version" require_relative "telebugs/config" require_relative "telebugs/promise" +require_relative "telebugs/notifier" +require_relative "telebugs/sender" require_relative "telebugs/wrapped_error" +require_relative "telebugs/notice" module Telebugs # The general error that this library uses when it wants to raise. Error = Class.new(StandardError) + HTTPError = Class.new(Error) + class << self def configure - yield Telebugs::Config.instance + yield Config.instance end def notify(error:) - Telebugs::Promise.new(error) do - end + Notifier.instance.notify(error) end end end diff --git a/lib/telebugs/config.rb b/lib/telebugs/config.rb index 95c1c36..7bf0e08 100644 --- a/lib/telebugs/config.rb +++ b/lib/telebugs/config.rb @@ -4,7 +4,10 @@ module Telebugs # Represents the Telebugs config. A config contains all the options that you # can use to configure a +Telebugs::Notifier+ instance. class Config + ERROR_API_URL = "https://api.telebugs.com/2024-03-28/errors" + attr_accessor :api_key + attr_reader :api_url class << self attr_writer :instance @@ -15,7 +18,16 @@ def instance end def initialize - @api_key = nil + reset + end + + def api_url=(url) + @api_url = URI(url) + end + + def reset + self.api_key = nil + self.api_url = ERROR_API_URL end end end diff --git a/lib/telebugs/notice.rb b/lib/telebugs/notice.rb new file mode 100644 index 0000000..ab8a98c --- /dev/null +++ b/lib/telebugs/notice.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Telebugs + # Represents a piece of information that will be sent to Telebugs API to + # report an error. + class Notice + # The list of possible exceptions that might be raised when an object is + # converted to JSON + JSON_EXCEPTIONS = [ + IOError, + NotImplementedError, + JSON::GeneratorError, + Encoding::UndefinedConversionError + ].freeze + + # On Ruby 3.1+, the error highlighting gem can produce messages that can + # span over multiple lines. We don't want to display multiline error titles. + # Therefore, we want to strip out the higlighting part so that the errors + # look consistent. + RUBY_31_ERROR_HIGHLIGHTING_DIVIDER = "\n\n" + + # The options for +String#encode+ + ENCODING_OPTIONS = {invalid: :replace, undef: :replace}.freeze + + # The maxium size of the JSON payload in bytes + MAX_NOTICE_SIZE = 64000 + + def initialize(error) + @payload = { + errors: errors_as_json(error) + } + end + + # Converts the notice to JSON. Calls +to_json+ on each object inside + # notice's payload. Truncates notices, JSON representation of which is + # bigger than {MAX_NOTICE_SIZE}. + def to_json(*_args) + loop do + begin + json = @payload.to_json + rescue *JSON_EXCEPTIONS + # TODO + else + return json if json && json.bytesize <= MAX_NOTICE_SIZE + end + + break if truncate == 0 + end + end + + private + + def errors_as_json(error) + WrappedError.new(error).unwrap.map do |e| + { + type: e.class.name, + message: message(e) + } + end + end + + def message(error) + return unless (msg = error.message) + + msg.encode(Encoding::UTF_8, **ENCODING_OPTIONS) + .split(RUBY_31_ERROR_HIGHLIGHTING_DIVIDER) + .first + end + + def truncate + 0 + end + end +end diff --git a/lib/telebugs/notifier.rb b/lib/telebugs/notifier.rb new file mode 100644 index 0000000..0d3e4fc --- /dev/null +++ b/lib/telebugs/notifier.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Telebugs + # Notifier is reponsible for sending notices to Telebugs. + class Notifier + class << self + attr_writer :instance + + def instance + @instance ||= new + end + end + + def initialize + @sender = Sender.new + end + + def notify(error) + Telebugs::Promise.new(error) do + @sender.send(error) + end + end + end +end diff --git a/lib/telebugs/promise.rb b/lib/telebugs/promise.rb index 8026658..d34a73e 100644 --- a/lib/telebugs/promise.rb +++ b/lib/telebugs/promise.rb @@ -11,5 +11,21 @@ def initialize(...) def value @future.value end + + def reason + @future.reason + end + + def wait + @future.wait + end + + def fulfilled? + @future.fulfilled? + end + + def rejected? + @future.rejected? + end end end diff --git a/lib/telebugs/sender.rb b/lib/telebugs/sender.rb new file mode 100644 index 0000000..14c4bf8 --- /dev/null +++ b/lib/telebugs/sender.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Telebugs + # Responsible for sending HTTP requests to Telebugs. + class Sender + CONTENT_TYPE = "application/json" + + USER_AGENT = "telebugs-ruby/#{Telebugs::VERSION} (#{RUBY_ENGINE}/#{RUBY_VERSION})" + + def initialize + @config = Config.instance + @authorization = "Bearer #{@config.api_key}" + end + + def send(data) + req = build_request(@config.api_url, data) + + resp = build_https(@config.api_url).request(req) + if resp.code_type == Net::HTTPCreated + return JSON.parse(resp.body) + end + + raise HTTPError, "#{resp.code_type} (#{resp.code}): #{JSON.parse(resp.body)}" + end + + private + + def build_request(uri, data) + Net::HTTP::Post.new(uri.request_uri).tap do |req| + req["Authorization"] = @authorization + req["Content-Type"] = CONTENT_TYPE + req["User-Agent"] = USER_AGENT + + req.body = data.to_json + end + end + + def build_https(uri) + Net::HTTP.new(uri.host, uri.port).tap do |https| + https.use_ssl = uri.is_a?(URI::HTTPS) + end + end + end +end diff --git a/telebugs.gemspec b/telebugs.gemspec index d49d1f5..88e8ce4 100644 --- a/telebugs.gemspec +++ b/telebugs.gemspec @@ -34,6 +34,8 @@ Gem::Specification.new do |spec| spec.add_dependency "concurrent-ruby", "~> 1.3" + spec.add_development_dependency "webmock", "~> 3.23" + # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html spec.metadata["rubygems_mfa_required"] = "true" diff --git a/test/test_config.rb b/test/test_config.rb new file mode 100644 index 0000000..3a79076 --- /dev/null +++ b/test/test_config.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestConfig < Minitest::Test + def teardown + Telebugs::Config.instance.reset + end + + def test_api_key + Telebugs.configure { |c| c.api_key = "12345:abcdef" } + + assert_equal "12345:abcdef", Telebugs::Config.instance.api_key + end + + def test_error_api_url + Telebugs.configure { |c| c.api_url = "example.com" } + + assert_equal URI("example.com"), Telebugs::Config.instance.api_url + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index e7aeea9..230068c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,3 +4,4 @@ require "telebugs" require "minitest/autorun" +require "webmock/minitest" diff --git a/test/test_notice.rb b/test/test_notice.rb new file mode 100644 index 0000000..ebb8bb1 --- /dev/null +++ b/test/test_notice.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestNotice < Minitest::Test + def test_to_json_with_nested_errors + begin + raise StandardError.new("error 1") + rescue => _ + begin + raise StandardError.new("error 2") + rescue => e2 + n = Telebugs::Notice.new(e2) + end + end + + assert_equal( + { + "errors" => [ + { + "type" => "StandardError", + "message" => "error 2" + }, + { + "type" => "StandardError", + "message" => "error 1" + } + ] + }, + JSON.parse(n.to_json) + ) + end + + def test_to_json_with_error_highlighting_in_messages + begin + raise "undefined method `[]' for nil:NilClass\n\n " \ + "data[:result].first[:first_name]\n ^^^^^^^^^^^^^" + rescue => e + end + + n = Telebugs::Notice.new(e) + + assert_equal( + { + "errors" => [ + { + "type" => "RuntimeError", + "message" => "undefined method `[]' for nil:NilClass" + } + ] + }, + JSON.parse(n.to_json) + ) + end + + def test_to_json_when_error_message_contains_invalid_characters + begin + JSON.parse(Marshal.dump(Time.now)) + rescue JSON::ParserError => e + end + + n = Telebugs::Notice.new(e) + json = JSON.parse(n.to_json) + + assert_equal "JSON::ParserError", json["errors"].first["type"] + assert_match(/unexpected token at/, json["errors"].first["message"]) + end + + def test_to_json_when_error_message_is_nil + error = Class.new(StandardError) { + def message + end + }.new + + n = Telebugs::Notice.new(error) + + assert_equal( + { + "errors" => [ + { + "type" => nil, + "message" => nil + } + ] + }, + JSON.parse(n.to_json) + ) + end +end diff --git a/test/test_notifier.rb b/test/test_notifier.rb new file mode 100644 index 0000000..f68fced --- /dev/null +++ b/test/test_notifier.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestNotifier < Minitest::Test + def teardown + WebMock.reset! + end + + def test_notify_returns_a_promise_that_resolves_to_a_hash + stub_request(:post, Telebugs::Config.instance.api_url) + .to_return(status: 201, body: {id: "123"}.to_json) + + p = Telebugs::Notifier.new.notify(StandardError.new) + + assert_equal({"id" => "123"}, p.value) + end +end diff --git a/test/test_promise.rb b/test/test_promise.rb index b03f40c..3c97798 100644 --- a/test/test_promise.rb +++ b/test/test_promise.rb @@ -3,9 +3,29 @@ require "test_helper" class TestPromise < Minitest::Test - def test_future_value - promise = Telebugs::Promise.new { 1 + 1 } + def test_value + p = Telebugs::Promise.new { 1 + 1 } - assert_equal 2, promise.value + assert_equal 2, p.value + end + + def test_reason + p = Telebugs::Promise.new { raise "error" } + + assert_equal "error", p.reason.message + end + + def test_fulfilled + p = Telebugs::Promise.new { 1 + 1 } + p.wait + + assert p.fulfilled? + end + + def test_rejected + p = Telebugs::Promise.new { raise "error" } + p.wait + + assert p.rejected? end end diff --git a/test/test_sender.rb b/test/test_sender.rb new file mode 100644 index 0000000..710f24d --- /dev/null +++ b/test/test_sender.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestSender < Minitest::Test + def teardown + Telebugs::Config.instance.reset + WebMock.reset! + end + + def test_send_attaches_correct_authorization_headers + Telebugs.configure { |c| c.api_key = "12345:abcdef" } + + stub = stub_request(:post, Telebugs::Config.instance.api_url) + .to_return(status: 201, body: {id: "123"}.to_json) + + Telebugs::Sender.new.send({"errors" => []}) + + assert_requested stub.with( + headers: { + "Authorization" => "Bearer 12345:abcdef", + "Content-Type" => "application/json", + "User-Agent" => "telebugs-ruby/#{Telebugs::VERSION} (ruby/#{RUBY_VERSION})" + }, + body: {"errors" => []}.to_json + ) + end + + def test_send_raises_http_error_when_response_code_is_not_created + stub = stub_request(:post, Telebugs::Config.instance.api_url) + .to_return(status: 500, body: {"error" => "oops"}.to_json) + + assert_raises(Telebugs::HTTPError) do + Telebugs::Sender.new.send({"errors" => []}) + end + + assert_requested stub + end +end diff --git a/test/test_telebugs.rb b/test/test_telebugs.rb index 3170d26..9585329 100644 --- a/test/test_telebugs.rb +++ b/test/test_telebugs.rb @@ -3,6 +3,11 @@ require "test_helper" class TestTelebugs < Minitest::Test + def teardown + Telebugs::Config.instance.reset + WebMock.reset! + end + def test_that_it_has_a_version_number refute_nil ::Telebugs::VERSION end @@ -17,9 +22,22 @@ def test_configure_configures_project_key assert_equal key, Telebugs::Config.instance.api_key end - def test_notify_returns_a_future - future = Telebugs.notify(error: StandardError.new) + def test_notify_returns_a_fullfilled_promise_when_request_succeeds + stub_request(:post, Telebugs::Config.instance.api_url) + .to_return(status: 201, body: {id: "123"}.to_json) + + p = Telebugs.notify(error: StandardError.new) + p.wait + + assert p.fulfilled? + end + + def test_notify_returns_a_rejected_promise_when_request_fails + stub_request(:post, Telebugs::Config.instance.api_url).to_return(status: 500) + + p = Telebugs.notify(error: StandardError.new) + p.wait - assert_instance_of Telebugs::Promise, future + assert p.rejected? end end