Skip to content

Commit

Permalink
Introduce Middleware DEFAULT_OPTIONS with Application and Instance Co…
Browse files Browse the repository at this point in the history
…nfigurability (#1572)
  • Loading branch information
ryan-mcneil authored Jul 5, 2024
1 parent 1958cb1 commit d83a281
Show file tree
Hide file tree
Showing 7 changed files with 308 additions and 12 deletions.
43 changes: 43 additions & 0 deletions docs/middleware/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,49 @@ conn = Faraday.new do |f|
end
```

### DEFAULT_OPTIONS

`DEFAULT_OPTIONS` improve the flexibility and customizability of new and existing middleware. Class-level `DEFAULT_OPTIONS` and the ability to set these defaults at the application level compliment existing functionality in which options can be passed into middleware on a per-instance basis.

#### Using DEFAULT_OPTIONS

Using `RaiseError` as an example, you can see that `DEFAULT_OPTIONS` have been defined at the top of the class:

```ruby
DEFAULT_OPTIONS = { include_request: true }.freeze
```

These options will be set at the class level upon instantiation and referenced as needed within the class. From our same example:

```ruby
def response_values(env)
...
return response unless options[:include_request]
...
```

If the default value provides the desired functionality, no further consideration is needed.

#### Setting Alternative Options per Application

In the case where it is desirable to change the default option for all instances within an application, it can be done by configuring the options in a `/config/initializers` file. For example:

```ruby
# config/initializers/faraday_config.rb

Faraday::Response::RaiseError.default_options = { include_request: false }
```

After app initialization, all instances of the middleware will have the newly configured option(s). They can still be overriden on a per-instance bases (if handled in the middleware), like this:

```ruby
Faraday.new do |f|
...
f.response :raise_error, include_request: true
...
end
```

### Available Middleware

The following pages provide detailed configuration for the middleware that ships with Faraday:
Expand Down
4 changes: 4 additions & 0 deletions lib/faraday/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,8 @@ class SSLError < Error
# Raised by middlewares that parse the response, like the JSON response middleware.
class ParsingError < Error
end

# Raised by Faraday::Middleware and subclasses when invalid default_options are used
class InitializationError < Error
end
end
44 changes: 43 additions & 1 deletion lib/faraday/middleware.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,57 @@
# frozen_string_literal: true

require 'monitor'

module Faraday
# Middleware is the basic base class of any Faraday middleware.
class Middleware
extend MiddlewareRegistry

attr_reader :app, :options

DEFAULT_OPTIONS = {}.freeze

def initialize(app = nil, options = {})
@app = app
@options = options
@options = self.class.default_options.merge(options)
end

class << self
# Faraday::Middleware::default_options= allows user to set default options at the Faraday::Middleware
# class level.
#
# @example Set the Faraday::Response::RaiseError option, `include_request` to `false`
# my_app/config/initializers/my_faraday_middleware.rb
#
# Faraday::Response::RaiseError.default_options = { include_request: false }
#
def default_options=(options = {})
validate_default_options(options)
lock.synchronize do
@default_options = default_options.merge(options)
end
end

# default_options attr_reader that initializes class instance variable
# with the values of any Faraday::Middleware defaults, and merges with
# subclass defaults
def default_options
@default_options ||= DEFAULT_OPTIONS.merge(self::DEFAULT_OPTIONS)
end

private

def lock
@lock ||= Monitor.new
end

def validate_default_options(options)
invalid_keys = options.keys.reject { |opt| self::DEFAULT_OPTIONS.key?(opt) }
return unless invalid_keys.any?

raise(Faraday::InitializationError,
"Invalid options provided. Keys not found in #{self}::DEFAULT_OPTIONS: #{invalid_keys.join(', ')}")
end
end

def call(env)
Expand Down
4 changes: 3 additions & 1 deletion lib/faraday/response/raise_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class RaiseError < Middleware
ServerErrorStatuses = (500...600)
# rubocop:enable Naming/ConstantName

DEFAULT_OPTIONS = { include_request: true }.freeze

def on_complete(env)
case env[:status]
when 400
Expand Down Expand Up @@ -58,7 +60,7 @@ def response_values(env)

# Include the request data by default. If the middleware was explicitly
# configured to _not_ include request data, then omit it.
return response unless options.fetch(:include_request, true)
return response unless options[:include_request]

response.merge(
request: {
Expand Down
143 changes: 143 additions & 0 deletions spec/faraday/middleware_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,147 @@ def on_error(error)
end
end
end

describe '::default_options' do
let(:subclass_no_options) { FaradayMiddlewareSubclasses::SubclassNoOptions }
let(:subclass_one_option) { FaradayMiddlewareSubclasses::SubclassOneOption }
let(:subclass_two_options) { FaradayMiddlewareSubclasses::SubclassTwoOptions }

def build_conn(resp_middleware)
Faraday.new do |c|
c.adapter :test do |stub|
stub.get('/success') { [200, {}, 'ok'] }
end
c.response resp_middleware
end
end

RSpec.shared_context 'reset @default_options' do
before(:each) do
FaradayMiddlewareSubclasses::SubclassNoOptions.instance_variable_set(:@default_options, nil)
FaradayMiddlewareSubclasses::SubclassOneOption.instance_variable_set(:@default_options, nil)
FaradayMiddlewareSubclasses::SubclassTwoOptions.instance_variable_set(:@default_options, nil)
Faraday::Middleware.instance_variable_set(:@default_options, nil)
end
end

after(:all) do
FaradayMiddlewareSubclasses::SubclassNoOptions.instance_variable_set(:@default_options, nil)
FaradayMiddlewareSubclasses::SubclassOneOption.instance_variable_set(:@default_options, nil)
FaradayMiddlewareSubclasses::SubclassTwoOptions.instance_variable_set(:@default_options, nil)
Faraday::Middleware.instance_variable_set(:@default_options, nil)
end

context 'with subclass DEFAULT_OPTIONS defined' do
include_context 'reset @default_options'

context 'and without application options configured' do
let(:resp1) { build_conn(:one_option).get('/success') }

it 'has only subclass defaults' do
expect(Faraday::Middleware.default_options).to eq(Faraday::Middleware::DEFAULT_OPTIONS)
expect(subclass_no_options.default_options).to eq(subclass_no_options::DEFAULT_OPTIONS)
expect(subclass_one_option.default_options).to eq(subclass_one_option::DEFAULT_OPTIONS)
expect(subclass_two_options.default_options).to eq(subclass_two_options::DEFAULT_OPTIONS)
end

it { expect(resp1.body).to eq('ok') }
end

context "and with one application's options changed" do
let(:resp2) { build_conn(:two_options).get('/success') }

before(:each) do
FaradayMiddlewareSubclasses::SubclassTwoOptions.default_options = { some_option: false }
end

it 'only updates default options of target subclass' do
expect(Faraday::Middleware.default_options).to eq(Faraday::Middleware::DEFAULT_OPTIONS)
expect(subclass_no_options.default_options).to eq(subclass_no_options::DEFAULT_OPTIONS)
expect(subclass_one_option.default_options).to eq(subclass_one_option::DEFAULT_OPTIONS)
expect(subclass_two_options.default_options).to eq({ some_option: false, some_other_option: false })
end

it { expect(resp2.body).to eq('ok') }
end

context "and with two applications' options changed" do
let(:resp1) { build_conn(:one_option).get('/success') }
let(:resp2) { build_conn(:two_options).get('/success') }

before(:each) do
FaradayMiddlewareSubclasses::SubclassOneOption.default_options = { some_other_option: true }
FaradayMiddlewareSubclasses::SubclassTwoOptions.default_options = { some_option: false }
end

it 'updates subclasses and parent independent of each other' do
expect(Faraday::Middleware.default_options).to eq(Faraday::Middleware::DEFAULT_OPTIONS)
expect(subclass_no_options.default_options).to eq(subclass_no_options::DEFAULT_OPTIONS)
expect(subclass_one_option.default_options).to eq({ some_other_option: true })
expect(subclass_two_options.default_options).to eq({ some_option: false, some_other_option: false })
end

it { expect(resp1.body).to eq('ok') }
it { expect(resp2.body).to eq('ok') }
end
end

context 'with FARADAY::MIDDLEWARE DEFAULT_OPTIONS and with Subclass DEFAULT_OPTIONS' do
before(:each) do
stub_const('Faraday::Middleware::DEFAULT_OPTIONS', { its_magic: false })
end

# Must stub Faraday::Middleware::DEFAULT_OPTIONS before resetting default options
include_context 'reset @default_options'

context 'and without application options configured' do
let(:resp1) { build_conn(:one_option).get('/success') }

it 'has only subclass defaults' do
expect(Faraday::Middleware.default_options).to eq(Faraday::Middleware::DEFAULT_OPTIONS)
expect(FaradayMiddlewareSubclasses::SubclassNoOptions.default_options).to eq({ its_magic: false })
expect(FaradayMiddlewareSubclasses::SubclassOneOption.default_options).to eq({ its_magic: false, some_other_option: false })
expect(FaradayMiddlewareSubclasses::SubclassTwoOptions.default_options).to eq({ its_magic: false, some_option: true, some_other_option: false })
end

it { expect(resp1.body).to eq('ok') }
end

context "and with two applications' options changed" do
let(:resp1) { build_conn(:one_option).get('/success') }
let(:resp2) { build_conn(:two_options).get('/success') }

before(:each) do
FaradayMiddlewareSubclasses::SubclassOneOption.default_options = { some_other_option: true }
FaradayMiddlewareSubclasses::SubclassTwoOptions.default_options = { some_option: false }
end

it 'updates subclasses and parent independent of each other' do
expect(Faraday::Middleware.default_options).to eq(Faraday::Middleware::DEFAULT_OPTIONS)
expect(FaradayMiddlewareSubclasses::SubclassNoOptions.default_options).to eq({ its_magic: false })
expect(FaradayMiddlewareSubclasses::SubclassOneOption.default_options).to eq({ its_magic: false, some_other_option: true })
expect(FaradayMiddlewareSubclasses::SubclassTwoOptions.default_options).to eq({ its_magic: false, some_option: false, some_other_option: false })
end

it { expect(resp1.body).to eq('ok') }
it { expect(resp2.body).to eq('ok') }
end
end

describe 'default_options input validation' do
include_context 'reset @default_options'

it 'raises error if Faraday::Middleware option does not exist' do
expect { Faraday::Middleware.default_options = { something_special: true } }.to raise_error(Faraday::InitializationError) do |e|
expect(e.message).to eq('Invalid options provided. Keys not found in Faraday::Middleware::DEFAULT_OPTIONS: something_special')
end
end

it 'raises error if subclass option does not exist' do
expect { subclass_one_option.default_options = { this_is_a_typo: true } }.to raise_error(Faraday::InitializationError) do |e|
expect(e.message).to eq('Invalid options provided. Keys not found in FaradayMiddlewareSubclasses::SubclassOneOption::DEFAULT_OPTIONS: this_is_a_typo')
end
end
end
end
end
64 changes: 54 additions & 10 deletions spec/faraday/response/raise_error_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,60 @@
end
end

context 'when the include_request option is set to false' do
let(:middleware_options) { { include_request: false } }

it 'does not include request info in the exception' do
expect { perform_request }.to raise_error(Faraday::BadRequestError) do |ex|
expect(ex.response.keys).to contain_exactly(
:status,
:headers,
:body
)
describe 'DEFAULT_OPTION: include_request' do
before(:each) do
Faraday::Response::RaiseError.instance_variable_set(:@default_options, nil)
Faraday::Middleware.instance_variable_set(:@default_options, nil)
end

after(:all) do
Faraday::Response::RaiseError.instance_variable_set(:@default_options, nil)
Faraday::Middleware.instance_variable_set(:@default_options, nil)
end

context 'when RaiseError DEFAULT_OPTION (include_request: true) is used' do
it 'includes request info in the exception' do
expect { perform_request }.to raise_error(Faraday::BadRequestError) do |ex|
expect(ex.response.keys).to contain_exactly(
:status,
:headers,
:body,
:request
)
end
end
end

context 'when application sets default_options `include_request: false`' do
before(:each) do
Faraday::Response::RaiseError.default_options = { include_request: false }
end

context 'and when include_request option is omitted' do
it 'does not include request info in the exception' do
expect { perform_request }.to raise_error(Faraday::BadRequestError) do |ex|
expect(ex.response.keys).to contain_exactly(
:status,
:headers,
:body
)
end
end
end

context 'and when include_request option is explicitly set for instance' do
let(:middleware_options) { { include_request: true } }

it 'includes request info in the exception' do
expect { perform_request }.to raise_error(Faraday::BadRequestError) do |ex|
expect(ex.response.keys).to contain_exactly(
:status,
:headers,
:body,
:request
)
end
end
end
end
end
Expand Down
18 changes: 18 additions & 0 deletions spec/support/faraday_middleware_subclasses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module FaradayMiddlewareSubclasses
class SubclassNoOptions < Faraday::Middleware
end

class SubclassOneOption < Faraday::Middleware
DEFAULT_OPTIONS = { some_other_option: false }.freeze
end

class SubclassTwoOptions < Faraday::Middleware
DEFAULT_OPTIONS = { some_option: true, some_other_option: false }.freeze
end
end

Faraday::Response.register_middleware(no_options: FaradayMiddlewareSubclasses::SubclassNoOptions)
Faraday::Response.register_middleware(one_option: FaradayMiddlewareSubclasses::SubclassOneOption)
Faraday::Response.register_middleware(two_options: FaradayMiddlewareSubclasses::SubclassTwoOptions)

0 comments on commit d83a281

Please sign in to comment.