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 3 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 `encode`
* 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, :encode]
iMacTia marked this conversation as resolved.
Show resolved Hide resolved
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 `decode`
* 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, :encode] }
iMacTia marked this conversation as resolved.
Show resolved Hide resolved
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?(:encode)
options[:encoder].encode(data)
else
::JSON.generate data
iMacTia marked this conversation as resolved.
Show resolved Hide resolved
end
iMacTia marked this conversation as resolved.
Show resolved Hide resolved
end

def match_content_type(env)
Expand Down
17 changes: 16 additions & 1 deletion lib/faraday/response/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,22 @@ def process_response(env)
end

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

parser_options = @parser_options.dup || {}

decoder, method_name =
if parser_options[:decoder].is_a?(Array) && parser_options[:decoder].size >= 2
parser_options[:decoder].slice(0, 2)
elsif parser_options[:decoder].respond_to?(:decode)
[parser_options[:decoder], :decode]
else
[::JSON, :parse]
end

parser_options.delete(:decoder)
iMacTia marked this conversation as resolved.
Show resolved Hide resolved

decoder.public_send(method_name, body, parser_options)
end

def parse_response?(env)
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(:encode) { |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' do
expect(encoder).to receive(:encode).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, :encode] }) }

it 'calls specified JSON encoder' do
expect(encoder).to receive(:encode).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(:decode) { |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 decode method' do
expect(decoder).to receive(:decode)
.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, :decode],
option: :option_value,
symbolize_names: true
}
}
end

it 'passes relevant options to specified decoder\'s method' do
expect(decoder).to receive(:decode)
.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