Skip to content

Commit

Permalink
Configurable JSON encoders and decoders (#1539)
Browse files Browse the repository at this point in the history
  • Loading branch information
ne006 committed Dec 20, 2023
1 parent b8e2e45 commit 1e81e2c
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 5 deletions.
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

0 comments on commit 1e81e2c

Please sign in to comment.