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

Configurable JSON encoders and decoders #1539

Merged
merged 8 commits into from
Dec 20, 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
40 changes: 40 additions & 0 deletions docs/middleware/included/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ conn.post('/', { a: 1, b: 2 })
# Body: {"a":1,"b":2}
```

### Using custom JSON encoders

By default, middleware utilizes Ruby's `json` to generate JSON strings.

Other encoders can be used by specifying `encoder` option for the middleware:
* a module/class which implements `dump`
* a module/class-method pair to be used

```ruby
require 'oj'

Faraday.new(...) do |f|
f.request :json, encoder: Oj
end

Faraday.new(...) do |f|
f.request :json, encoder: [Oj, :dump]
end
```

## JSON Responses

The `JSON` response middleware parses response body into a hash of key/value pairs.
Expand All @@ -39,3 +59,23 @@ end
conn.get('json').body
# => {"slideshow"=>{"author"=>"Yours Truly", "date"=>"date of publication", "slides"=>[{"title"=>"Wake up to WonderWidgets!", "type"=>"all"}, {"items"=>["Why <em>WonderWidgets</em> are great", "Who <em>buys</em> WonderWidgets"], "title"=>"Overview", "type"=>"all"}], "title"=>"Sample Slide Show"}}
```

### Using custom JSON decoders

By default, middleware utilizes Ruby's `json` to parse JSON strings.

Other decoders can be used by specifying `decoder` parser option for the middleware:
* a module/class which implements `load`
* a module/class-method pair to be used

```ruby
require 'oj'

Faraday.new(...) do |f|
f.response :json, parser_options: { decoder: Oj }
end

Faraday.new(...) do |f|
f.response :json, parser_options: { decoder: [Oj, :load] }
end
```
6 changes: 3 additions & 3 deletions lib/faraday/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ def update(obj)
new_value = value
end

send("#{key}=", new_value) unless new_value.nil?
send(:"#{key}=", new_value) unless new_value.nil?
end
self
end

# Public
def delete(key)
value = send(key)
send("#{key}=", nil)
send(:"#{key}=", nil)
value
end

Expand All @@ -57,7 +57,7 @@ def merge!(other)
else
other_value
end
send("#{key}=", new_value) unless new_value.nil?
send(:"#{key}=", new_value) unless new_value.nil?
end
self
end
Expand Down
8 changes: 7 additions & 1 deletion lib/faraday/request/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ def on_request(env)
private

def encode(data)
::JSON.generate(data)
if options[:encoder].is_a?(Array) && options[:encoder].size >= 2
options[:encoder][0].public_send(options[:encoder][1], data)
elsif options[:encoder].respond_to?(:dump)
options[:encoder].dump(data)
else
::JSON.generate(data)
end
end

def match_content_type(env)
Expand Down
21 changes: 20 additions & 1 deletion lib/faraday/response/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ def initialize(app = nil, parser_options: nil, content_type: /\bjson$/, preserve
@parser_options = parser_options
@content_types = Array(content_type)
@preserve_raw = preserve_raw

process_parser_options
end

def on_complete(env)
Expand All @@ -27,7 +29,11 @@ def process_response(env)
end

def parse(body)
::JSON.parse(body, @parser_options || {}) unless body.strip.empty?
return if body.strip.empty?

decoder, method_name = @decoder_options

decoder.public_send(method_name, body, @parser_options || {})
end

def parse_response?(env)
Expand All @@ -47,6 +53,19 @@ def response_type(env)
type = type.split(';', 2).first if type.index(';')
type
end

def process_parser_options
@decoder_options = @parser_options&.delete(:decoder)

@decoder_options =
if @decoder_options.is_a?(Array) && @decoder_options.size >= 2
@decoder_options.slice(0, 2)
elsif @decoder_options.respond_to?(:load)
[@decoder_options, :load]
else
[::JSON, :parse]
end
end
end
end
end
Expand Down
64 changes: 64 additions & 0 deletions spec/faraday/request/json_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,68 @@ def result_type
expect(result_type).to eq('application/xml; charset=utf-8')
end
end

context 'with encoder' do
let(:encoder) do
double('Encoder').tap do |e|
allow(e).to receive(:dump) { |s, opts| JSON.generate(s, opts) }
end
end

let(:result) { process(a: 1) }

context 'when encoder is passed as object' do
let(:middleware) { described_class.new(->(env) { Faraday::Response.new(env) }, { encoder: encoder }) }

it 'calls specified JSON encoder\'s dump method' do
expect(encoder).to receive(:dump).with({ a: 1 })

result
end

it 'encodes body' do
expect(result_body).to eq('{"a":1}')
end

it 'adds content type' do
expect(result_type).to eq('application/json')
end
end

context 'when encoder is passed as an object-method pair' do
let(:middleware) { described_class.new(->(env) { Faraday::Response.new(env) }, { encoder: [encoder, :dump] }) }

it 'calls specified JSON encoder' do
expect(encoder).to receive(:dump).with({ a: 1 })

result
end

it 'encodes body' do
expect(result_body).to eq('{"a":1}')
end

it 'adds content type' do
expect(result_type).to eq('application/json')
end
end

context 'when encoder is not passed' do
let(:middleware) { described_class.new(->(env) { Faraday::Response.new(env) }) }

it 'calls JSON.generate' do
expect(JSON).to receive(:generate).with({ a: 1 })

result
end

it 'encodes body' do
expect(result_body).to eq('{"a":1}')
end

it 'adds content type' do
expect(result_type).to eq('application/json')
end
end
end
end
72 changes: 72 additions & 0 deletions spec/faraday/response/json_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,76 @@ def process(body, content_type = 'application/json', options = {})
expect(response.body).to eq(result)
end
end

context 'with decoder' do
let(:decoder) do
double('Decoder').tap do |e|
allow(e).to receive(:load) { |s, opts| JSON.parse(s, opts) }
end
end

let(:body) { '{"a": 1}' }
let(:result) { { a: 1 } }

context 'when decoder is passed as object' do
let(:options) do
{
parser_options: {
decoder: decoder,
option: :option_value,
symbolize_names: true
}
}
end

it 'passes relevant options to specified decoder\'s load method' do
expect(decoder).to receive(:load)
.with(body, { option: :option_value, symbolize_names: true })
.and_return(result)

response = process(body)
expect(response.body).to eq(result)
end
end

context 'when decoder is passed as an object-method pair' do
let(:options) do
{
parser_options: {
decoder: [decoder, :load],
option: :option_value,
symbolize_names: true
}
}
end

it 'passes relevant options to specified decoder\'s method' do
expect(decoder).to receive(:load)
.with(body, { option: :option_value, symbolize_names: true })
.and_return(result)

response = process(body)
expect(response.body).to eq(result)
end
end

context 'when decoder is not passed' do
let(:options) do
{
parser_options: {
symbolize_names: true
}
}
end

it 'passes relevant options to JSON parse' do
expect(JSON).to receive(:parse)
.with(body, { symbolize_names: true })
.and_return(result)

response = process(body)
expect(response.body).to eq(result)
end
end
end
end