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

fix(request): Handle edge cases in response parsing #29

Merged
merged 5 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ end
```

**Instantiating with a block:**

- **NOTE:** This pretty much works the same way as passing a block to `Faraday.new`. Any config you can use with `Faraday` directly, you can do with `HTTPigeon::Request`

```ruby
require "faraday/retry"

# @option [String] base_url the base URI (required)
request = HTTPigeon::Request.new(base_url: 'https://dummyjson.com') do |connection|
# connection is an instance of Faraday::Connection
connection.headers['foo'] = 'barzzz'
connection.options['timeout'] = 15
request = HTTPigeon::Request.new(base_url: 'https://dummyjson.com') do |config|
# config is an instance of Faraday::Connection
config.headers['foo'] = 'barzzz'
config.options['timeout'] = 15
config.request :retry, { max: 5 }
...
end

Expand Down
3 changes: 1 addition & 2 deletions bin/console
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ require "httpigeon"
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
require "pry"
# Pry.start

require "irb"
Expand Down
29 changes: 20 additions & 9 deletions lib/httpigeon/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ def post(endpoint, payload, headers = {}, event_type = nil, log_filters = [])

def initialize(base_url:, options: nil, headers: nil, adapter: nil, logger: nil, event_type: nil, log_filters: nil)
@base_url = base_url
@event_type = event_type
@log_filters = log_filters || []
@logger = logger || default_logger

request_headers = default_headers.merge(headers.to_h)
base_connection = Faraday.new(url: base_url)

base_connection = Faraday.new(url: base_url).tap do |config|
config.headers.deep_merge!(request_headers)
config.options.merge!(options.to_h)
config.response :httpigeon_logger, logger if logger.is_a?(HTTPigeon::Logger)
end

@connection = if block_given?
yield(base_connection) && base_connection
Expand All @@ -47,7 +49,7 @@ def initialize(base_url:, options: nil, headers: nil, adapter: nil, logger: nil,
faraday.options.merge!(options.to_h)
faraday.request :url_encoded
faraday.adapter adapter || Faraday.default_adapter
faraday.response :httpigeon_logger, @logger
faraday.response :httpigeon_logger, default_logger(event_type, log_filters) unless logger.is_a?(HTTPigeon::Logger)
end
end
end
Expand All @@ -67,15 +69,24 @@ def run(method: :get, path: '/', payload: {})

private

attr_reader :path, :logger, :event_type, :log_filters
attr_reader :logger, :event_type, :log_filters

def parse_response
JSON.parse(response_body).with_indifferent_access
rescue JSON::ParserError
parsed_body = response_body.is_a?(String) ? JSON.parse(response_body) : response_body

case parsed_body
when Hash
parsed_body.with_indifferent_access
when Array
parsed_body.map(&:with_indifferent_access)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will still break in the case where the elements of the top-level array are themselves arrays or non-Object in general

for example, it would break on this valid json response in several different ways:

[
  {
    "foo": "bar"
  },
  1,
  "foobar",
  true,
  null,
  [
    {
      "inner": "object"
    },
    1,
    null,
    []
  ]
]

consider modifying the contract of the library to only return string-type json and thus eliminate this problem entirely -- simply don't try 😆

or, consider a safe recursive "deep with indifferent access." maybe something like this

def deep_with_indifferent_access(obj)
  case obj
  when Hash
    obj.each_with_object({}) do |(key, value), memo|
      memo[key] = deep_with_indifferent_access(value)
    end.with_indifferent_access
  when Array
    obj.map { |item| deep_with_indifferent_access(item) }
  else
    obj
  end
end

test = JSON.parse('[{"foo": "bar"},1,"foobar",true,null,[{"inner": "object"},1,null,[]]]')
result = deep_with_indifferent_access(test)
# => [{"foo"=>"bar"}, 1, "foobar", true, nil, [{"inner"=>"object"}, 1, nil, []]]

result[0][:foo]
# => "bar"

result[5][0][:inner]
# => "object"

note: the deep recursion implementation is probably bugged too 😅

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what the NoMethodError rescue is for hehe. "I tried to help but your thing is weird so I'm leaving it as-is, enjoy"

else
parsed_body
end
rescue JSON::ParserError, NoMethodError
response_body.presence
end

def default_logger
def default_logger(event_type, log_filters)
HTTPigeon::Logger.new(event_type: event_type, log_filters: log_filters)
end

Expand Down
156 changes: 126 additions & 30 deletions spec/httpigeon/request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,44 +42,140 @@
end

describe '#new' do
it 'sets the expected default headers' do
allow(SecureRandom).to receive(:uuid).and_return('secure-random-uuid')

test_table = [
{ # when auto generate request id is turned off
auto_generate_request_id: false,
request_headers: { 'Foo' => 'barzz' },
included_headers: { 'Accept' => 'application/json', 'Foo' => 'barzz' }
},
{ # when auto generate request id is turned on
auto_generate_request_id: true,
request_headers: { 'Foo' => 'barzz' },
included_headers: { 'Accept' => 'application/json', 'X-Request-Id' => 'secure-random-uuid', 'Foo' => 'barzz' }
}
]

test_table.each do |test_case|
allow(HTTPigeon).to receive(:auto_generate_request_id).and_return(test_case[:auto_generate_request_id])
request = described_class.new(base_url: 'https://www.example.com', headers: test_case[:request_headers], event_type: 'event.type', log_filters: [:super_secret])

expect(request.connection.headers.slice(*%w[Accept X-Request-Id Foo])).to eq(test_case[:included_headers])
end
let(:custom_logger_klass) { double('some-custom-logger-klass') }

before do
logger_instance = double('instance-of-custom-logger')

allow(logger_instance).to receive(:is_a?).with(HTTPigeon::Logger).and_return(true)
allow(custom_logger_klass).to receive(:new).and_return(logger_instance)
end

it 'uses the default logger if a custom logger is not provided' do
allow(HTTPigeon::Logger).to receive(:new)
context 'when a block is given' do
it 'sets the expected default headers' do
allow(SecureRandom).to receive(:uuid).and_return('secure-random-uuid')

test_table = [
{ # when auto generate request id is turned off
auto_generate_request_id: false,
request_headers: { 'Foo' => 'barzz' },
included_headers: { 'Accept' => 'application/json', 'Foo' => 'barzz' }
},
{ # when auto generate request id is turned on
auto_generate_request_id: true,
request_headers: { 'Foo' => 'barzz' },
included_headers: { 'Accept' => 'application/json', 'X-Request-Id' => 'secure-random-uuid', 'Foo' => 'barzz' }
}
]

test_table.each do |test_case|
allow(HTTPigeon).to receive(:auto_generate_request_id).and_return(test_case[:auto_generate_request_id])

request = described_class.new(base_url: 'https://www.example.com', headers: test_case[:request_headers]) do |conn|
conn.request :json
conn.response :json
end

expect(request.connection.headers.slice(*%w[Accept X-Request-Id Foo])).to eq(test_case[:included_headers])
end
end

it 'sets the request options if provided' do
request = described_class.new(base_url: 'https://www.example.com', options: { timeout: 10 }) do |conn|
conn.request :json
conn.response :json
end

expect(request.connection.options.timeout).to eq(10)
end

described_class.new(base_url: 'https://www.example.com', event_type: 'event.type', log_filters: [:super_secret])
it 'does not use the default logger if a custom logger is not provided' do
allow(HTTPigeon::Logger).to receive(:new)

described_class.new(base_url: 'https://www.example.com') do |conn|
conn.request :json
conn.response :json
end

expect(HTTPigeon::Logger).not_to have_received(:new)
end

it 'sets the custom :httpigeon_logger if provided and is a type of httpigeon logger' do
allow(HTTPigeon::Logger).to receive(:new)

request = described_class.new(base_url: 'https://www.example.com', logger: custom_logger_klass.new) do |conn|
conn.request :json
conn.response :json
end

expect(HTTPigeon::Logger).not_to have_received(:new)
expect(request.connection.builder.handlers).to include(HTTPigeon::Middleware::HTTPigeonLogger)
end

expect(HTTPigeon::Logger).to have_received(:new).with(event_type: 'event.type', log_filters: [:super_secret])
it 'does not set a custom :httpigeon_logger if provided and is not a type of httpigeon logger' do
allow(HTTPigeon::Logger).to receive(:new)

request = described_class.new(base_url: 'https://www.example.com', logger: Logger.new($stdout)) do |conn|
conn.request :json
conn.response :json
end

expect(HTTPigeon::Logger).not_to have_received(:new)
expect(request.connection.builder.handlers).not_to include(HTTPigeon::Middleware::HTTPigeonLogger)
end
end

it 'uses the custom logger if one if provided' do
allow(HTTPigeon::Logger).to receive(:new)
context 'when a block is not given' do
it 'sets the expected default headers' do
allow(SecureRandom).to receive(:uuid).and_return('secure-random-uuid')

test_table = [
{ # when auto generate request id is turned off
auto_generate_request_id: false,
request_headers: { 'Foo' => 'barzz' },
included_headers: { 'Accept' => 'application/json', 'Foo' => 'barzz' }
},
{ # when auto generate request id is turned on
auto_generate_request_id: true,
request_headers: { 'Foo' => 'barzz' },
included_headers: { 'Accept' => 'application/json', 'X-Request-Id' => 'secure-random-uuid', 'Foo' => 'barzz' }
}
]

test_table.each do |test_case|
allow(HTTPigeon).to receive(:auto_generate_request_id).and_return(test_case[:auto_generate_request_id])
request = described_class.new(base_url: 'https://www.example.com', headers: test_case[:request_headers], event_type: 'event.type', log_filters: [:super_secret])

expect(request.connection.headers.slice(*%w[Accept X-Request-Id Foo])).to eq(test_case[:included_headers])
end
end

it 'uses the default logger if a custom logger is not provided' do
allow(HTTPigeon::Logger).to receive(:new)

request = described_class.new(base_url: 'https://www.example.com', event_type: 'event.type', log_filters: [:super_secret])

expect(HTTPigeon::Logger).to have_received(:new).with(event_type: 'event.type', log_filters: [:super_secret])
expect(request.connection.builder.handlers).to include(HTTPigeon::Middleware::HTTPigeonLogger)
end

it 'uses the custom if provided and is a type of httpigeon logger' do
allow(HTTPigeon::Logger).to receive(:new)

described_class.new(base_url: 'http://www.example.com', logger: Logger.new($stdout))
request = described_class.new(base_url: 'http://www.example.com', logger: custom_logger_klass.new)

expect(HTTPigeon::Logger).not_to have_received(:new)
expect(request.connection.builder.handlers).to include(HTTPigeon::Middleware::HTTPigeonLogger)
end

expect(HTTPigeon::Logger).not_to have_received(:new)
it 'uses the default logger if a custom logger is provided but is not a type of httpigeon logger' do
allow(HTTPigeon::Logger).to receive(:new)

request = described_class.new(base_url: 'https://www.example.com', logger: Logger.new($stdout))

expect(HTTPigeon::Logger).to have_received(:new)
expect(request.connection.builder.handlers).to include(HTTPigeon::Middleware::HTTPigeonLogger)
end
end
end

Expand Down
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
end

require 'httpigeon'
require 'pry'

# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
# for more details on configuration
Expand Down
Loading