diff --git a/docs/middleware/list.md b/docs/middleware/list.md index 59d9ba2ae..88f4422cb 100644 --- a/docs/middleware/list.md +++ b/docs/middleware/list.md @@ -27,6 +27,8 @@ base64 representation. * [`Multipart`][multipart] converts a `Faraday::Request#body` hash of key/value pairs into a multipart form request. * [`UrlEncoded`][url_encoded] converts a `Faraday::Request#body` hash of key/value pairs into a url-encoded request body. +* [`Json Request`][json-request] converts a `Faraday::Request#body` hash of key/value pairs into a JSON request body. +* [`Json Response`][json-response] parses response body into a hash of key/value pairs. * [`Retry`][retry] automatically retries requests that fail due to intermittent client or server errors (such as network hiccups). * [`Instrumentation`][instrumentation] allows to instrument requests using different tools. @@ -44,7 +46,9 @@ before returning it. [authentication]: ./authentication [multipart]: ./multipart [url_encoded]: ./url-encoded +[json-request]: ./json-request [retry]: ./retry [instrumentation]: ./instrumentation +[json-response]: ./json-response [logger]: ./logger [raise_error]: ./raise-error diff --git a/docs/middleware/request/authentication.md b/docs/middleware/request/authentication.md index 867efa187..e15fd45fd 100644 --- a/docs/middleware/request/authentication.md +++ b/docs/middleware/request/authentication.md @@ -9,8 +9,8 @@ top_name: Back to Middleware top_link: ./list --- -Basic and Token authentication are handled by Faraday::Request::BasicAuthentication -and Faraday::Request::TokenAuthentication respectively. +Basic and Token authentication are handled by `Faraday::Request::BasicAuthentication` +and `Faraday::Request::TokenAuthentication` respectively. These can be added as middleware manually or through the helper methods. ### Basic Authentication diff --git a/docs/middleware/request/instrumentation.md b/docs/middleware/request/instrumentation.md index 33fcaae08..83adec6e6 100644 --- a/docs/middleware/request/instrumentation.md +++ b/docs/middleware/request/instrumentation.md @@ -5,8 +5,8 @@ permalink: /middleware/instrumentation hide: true prev_name: Retry Middleware prev_link: ./retry -next_name: Logger Middleware -next_link: ./logger +next_name: JSON Response Middleware +next_link: ./json-response top_name: Back to Middleware top_link: ./list --- diff --git a/docs/middleware/request/json.md b/docs/middleware/request/json.md new file mode 100644 index 000000000..9ba064ad9 --- /dev/null +++ b/docs/middleware/request/json.md @@ -0,0 +1,31 @@ +--- +layout: documentation +title: "JSON Request Middleware" +permalink: /middleware/json-request +hide: true +prev_name: UrlEncoded Middleware +prev_link: ./url-encoded +next_name: Retry Middleware +next_link: ./retry +top_name: Back to Middleware +top_link: ./list +--- + +The `JSON` request middleware converts a `Faraday::Request#body` hash of key/value pairs into a JSON request body. +The middleware also automatically sets the `Content-Type` header to `application/json`, +processes only requests with matching Content-Type or those without a type and +doesn't try to encode bodies that already are in string form. + +### Example Usage + +```ruby +conn = Faraday.new(...) do |f| + f.request :json + ... +end + +conn.post('/', { a: 1, b: 2 }) +# POST with +# Content-Type: application/json +# Body: {"a":1,"b":2} +``` diff --git a/docs/middleware/request/url_encoded.md b/docs/middleware/request/url_encoded.md index 95cdf528c..c913e3865 100644 --- a/docs/middleware/request/url_encoded.md +++ b/docs/middleware/request/url_encoded.md @@ -5,8 +5,8 @@ permalink: /middleware/url-encoded hide: true prev_name: Multipart Middleware prev_link: ./multipart -next_name: Retry Middleware -next_link: ./retry +next_name: JSON Request Middleware +next_link: ./json-request top_name: Back to Middleware top_link: ./list --- diff --git a/docs/middleware/response/json.md b/docs/middleware/response/json.md new file mode 100644 index 000000000..86e1560a7 --- /dev/null +++ b/docs/middleware/response/json.md @@ -0,0 +1,29 @@ +--- +layout: documentation +title: "JSON Response Middleware" +permalink: /middleware/json-response +hide: true +prev_name: Instrumentation Middleware +prev_link: ./instrumentation +next_name: Logger Middleware +next_link: ./logger +top_name: Back to Middleware +top_link: ./list +--- + +The `JSON` response middleware parses response body into a hash of key/value pairs. +The behaviour can be customized with the following options: +* **parser_options:** options that will be sent to the JSON.parse method. Defaults to {}. +* **content_type:** Single value or Array of response content-types that should be processed. Can be either strings or Regex. Defaults to `/\bjson$/`. +* **preserve_raw:** If set to true, the original un-parsed response will be stored in the `response.env[:raw_body]` property. Defaults to `false`. + +### Example Usage + +```ruby +conn = Faraday.new('http://httpbingo.org') do |f| + f.response :json, **options +end + +conn.get('json').body +# => {"slideshow"=>{"author"=>"Yours Truly", "date"=>"date of publication", "slides"=>[{"title"=>"Wake up to WonderWidgets!", "type"=>"all"}, {"items"=>["Why WonderWidgets are great", "Who buys WonderWidgets"], "title"=>"Overview", "type"=>"all"}], "title"=>"Sample Slide Show"}} +``` diff --git a/docs/middleware/response/logger.md b/docs/middleware/response/logger.md index fdee39279..e728d51f1 100644 --- a/docs/middleware/response/logger.md +++ b/docs/middleware/response/logger.md @@ -3,8 +3,8 @@ layout: documentation title: "Logger Middleware" permalink: /middleware/logger hide: true -prev_name: Instrumentation Middleware -prev_link: ./instrumentation +prev_name: JSON Response Middleware +prev_link: ./json-response next_name: RaiseError Middleware next_link: ./raise-error top_name: Back to Middleware diff --git a/lib/faraday.rb b/lib/faraday.rb index 82efb050a..9eb7778e7 100644 --- a/lib/faraday.rb +++ b/lib/faraday.rb @@ -33,6 +33,8 @@ # conn.get '/' # module Faraday + CONTENT_TYPE = 'Content-Type' + class << self # The root path that Faraday is being loaded from. # diff --git a/lib/faraday/request.rb b/lib/faraday/request.rb index e62113595..e2c45948c 100644 --- a/lib/faraday/request.rb +++ b/lib/faraday/request.rb @@ -123,11 +123,11 @@ def marshal_dump # @param serialised [Hash] the serialised object. def marshal_load(serialised) self.http_method = serialised[:http_method] - self.body = serialised[:body] - self.headers = serialised[:headers] - self.path = serialised[:path] - self.params = serialised[:params] - self.options = serialised[:options] + self.body = serialised[:body] + self.headers = serialised[:headers] + self.path = serialised[:path] + self.params = serialised[:params] + self.options = serialised[:options] end # @return [Env] the Env for this Request diff --git a/lib/faraday/request/json.rb b/lib/faraday/request/json.rb new file mode 100644 index 000000000..a17a4dd06 --- /dev/null +++ b/lib/faraday/request/json.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'json' + +module Faraday + class Request + # Request middleware that encodes the body as JSON. + # + # Processes only requests with matching Content-type or those without a type. + # If a request doesn't have a type but has a body, it sets the Content-type + # to JSON MIME-type. + # + # Doesn't try to encode bodies that already are in string form. + class Json < Middleware + MIME_TYPE = 'application/json' + MIME_TYPE_REGEX = %r{^application/(vnd\..+\+)?json$}.freeze + + def on_request(env) + match_content_type(env) do |data| + env[:body] = encode(data) + end + end + + private + + def encode(data) + ::JSON.generate(data) + end + + def match_content_type(env) + return unless process_request?(env) + + env[:request_headers][CONTENT_TYPE] ||= MIME_TYPE + yield env[:body] unless env[:body].respond_to?(:to_str) + end + + def process_request?(env) + type = request_type(env) + body?(env) && (type.empty? || type.match?(MIME_TYPE_REGEX)) + end + + def body?(env) + (body = env[:body]) && !(body.respond_to?(:to_str) && body.empty?) + end + + def request_type(env) + type = env[:request_headers][CONTENT_TYPE].to_s + type = type.split(';', 2).first if type.index(';') + type + end + end + end +end diff --git a/lib/faraday/response.rb b/lib/faraday/response.rb index 86243dbeb..187525a4b 100644 --- a/lib/faraday/response.rb +++ b/lib/faraday/response.rb @@ -26,6 +26,7 @@ def reason_phrase def headers finished? ? env.response_headers : {} end + def_delegator :headers, :[] def body diff --git a/lib/faraday/response/json.rb b/lib/faraday/response/json.rb new file mode 100644 index 000000000..19fa72047 --- /dev/null +++ b/lib/faraday/response/json.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'json' + +module Faraday + class Response + # Parse response bodies as JSON. + class Json < Middleware + def initialize(app = nil, parser_options: nil, content_type: /\bjson$/, preserve_raw: false) + super(app) + @parser_options = parser_options + @content_types = Array(content_type) + @preserve_raw = preserve_raw + end + + def on_complete(env) + process_response(env) if parse_response?(env) + end + + private + + def process_response(env) + env[:raw_body] = env[:body] if @preserve_raw + env[:body] = parse(env[:body]) + rescue StandardError, SyntaxError => e + raise Faraday::ParsingError.new(e, env[:response]) + end + + def parse(body) + ::JSON.parse(body, @parser_options || {}) unless body.strip.empty? + end + + def parse_response?(env) + process_response_type?(env) && + env[:body].respond_to?(:to_str) + end + + def process_response_type?(env) + type = response_type(env) + @content_types.empty? || @content_types.any? do |pattern| + pattern.is_a?(Regexp) ? type.match?(pattern) : type == pattern + end + end + + def response_type(env) + type = env[:response_headers][CONTENT_TYPE].to_s + type = type.split(';', 2).first if type.index(';') + type + end + end + end +end diff --git a/lib/faraday/response/logger.rb b/lib/faraday/response/logger.rb index 5cdfa1f54..387ce335c 100644 --- a/lib/faraday/response/logger.rb +++ b/lib/faraday/response/logger.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'forwardable' +require 'logger' require 'faraday/logging/formatter' module Faraday @@ -11,10 +12,7 @@ class Response class Logger < Middleware def initialize(app, logger = nil, options = {}) super(app) - logger ||= begin - require 'logger' - ::Logger.new($stdout) - end + logger ||= ::Logger.new($stdout) formatter_class = options.delete(:formatter) || Logging::Formatter @formatter = formatter_class.new(logger: logger, options: options) yield @formatter if block_given? diff --git a/spec/faraday/request/json_spec.rb b/spec/faraday/request/json_spec.rb new file mode 100644 index 000000000..89949bc2a --- /dev/null +++ b/spec/faraday/request/json_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +RSpec.describe Faraday::Request::Json do + let(:middleware) { described_class.new(->(env) { Faraday::Response.new(env) }) } + + def process(body, content_type = nil) + env = { body: body, request_headers: Faraday::Utils::Headers.new } + env[:request_headers]['content-type'] = content_type if content_type + middleware.call(Faraday::Env.from(env)).env + end + + def result_body + result[:body] + end + + def result_type + result[:request_headers]['content-type'] + end + + context 'no body' do + let(:result) { process(nil) } + + it "doesn't change body" do + expect(result_body).to be_nil + end + + it "doesn't add content type" do + expect(result_type).to be_nil + end + end + + context 'empty body' do + let(:result) { process('') } + + it "doesn't change body" do + expect(result_body).to be_empty + end + + it "doesn't add content type" do + expect(result_type).to be_nil + end + end + + context 'string body' do + let(:result) { process('{"a":1}') } + + it "doesn't change 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 'object body' do + let(:result) { process(a: 1) } + + 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 'empty object body' do + let(:result) { process({}) } + + it 'encodes body' do + expect(result_body).to eq('{}') + end + end + + context 'object body with json type' do + let(:result) { process({ a: 1 }, 'application/json; charset=utf-8') } + + it 'encodes body' do + expect(result_body).to eq('{"a":1}') + end + + it "doesn't change content type" do + expect(result_type).to eq('application/json; charset=utf-8') + end + end + + context 'object body with vendor json type' do + let(:result) { process({ a: 1 }, 'application/vnd.myapp.v1+json; charset=utf-8') } + + it 'encodes body' do + expect(result_body).to eq('{"a":1}') + end + + it "doesn't change content type" do + expect(result_type).to eq('application/vnd.myapp.v1+json; charset=utf-8') + end + end + + context 'object body with incompatible type' do + let(:result) { process({ a: 1 }, 'application/xml; charset=utf-8') } + + it "doesn't change body" do + expect(result_body).to eq(a: 1) + end + + it "doesn't change content type" do + expect(result_type).to eq('application/xml; charset=utf-8') + end + end +end diff --git a/spec/faraday/response/json_spec.rb b/spec/faraday/response/json_spec.rb new file mode 100644 index 000000000..a98e8a586 --- /dev/null +++ b/spec/faraday/response/json_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +RSpec.describe Faraday::Response::Json, type: :response do + let(:options) { {} } + let(:headers) { {} } + let(:middleware) do + described_class.new(lambda { |env| + Faraday::Response.new(env) + }, **options) + end + + def process(body, content_type = 'application/json', options = {}) + env = { + body: body, request: options, + request_headers: Faraday::Utils::Headers.new, + response_headers: Faraday::Utils::Headers.new(headers) + } + env[:response_headers]['content-type'] = content_type if content_type + yield(env) if block_given? + middleware.call(Faraday::Env.from(env)) + end + + context 'no type matching' do + it "doesn't change nil body" do + expect(process(nil).body).to be_nil + end + + it 'nullifies empty body' do + expect(process('').body).to be_nil + end + + it 'parses json body' do + response = process('{"a":1}') + expect(response.body).to eq('a' => 1) + expect(response.env[:raw_body]).to be_nil + end + end + + context 'with preserving raw' do + let(:options) { { preserve_raw: true } } + + it 'parses json body' do + response = process('{"a":1}') + expect(response.body).to eq('a' => 1) + expect(response.env[:raw_body]).to eq('{"a":1}') + end + end + + context 'with default regexp type matching' do + it 'parses json body of correct type' do + response = process('{"a":1}', 'application/x-json') + expect(response.body).to eq('a' => 1) + end + + it 'ignores json body of incorrect type' do + response = process('{"a":1}', 'text/json-xml') + expect(response.body).to eq('{"a":1}') + end + end + + context 'with array type matching' do + let(:options) { { content_type: %w[a/b c/d] } } + + it 'parses json body of correct type' do + expect(process('{"a":1}', 'a/b').body).to be_a(Hash) + expect(process('{"a":1}', 'c/d').body).to be_a(Hash) + end + + it 'ignores json body of incorrect type' do + expect(process('{"a":1}', 'a/d').body).not_to be_a(Hash) + end + end + + it 'chokes on invalid json' do + expect { process('{!') }.to raise_error(Faraday::ParsingError) + end + + it 'includes the response on the ParsingError instance' do + begin + process('{') { |env| env[:response] = Faraday::Response.new } + raise 'Parsing should have failed.' + rescue Faraday::ParsingError => e + expect(e.response).to be_a(Faraday::Response) + end + end + + context 'HEAD responses' do + it "nullifies the body if it's only one space" do + response = process(' ') + expect(response.body).to be_nil + end + + it "nullifies the body if it's two spaces" do + response = process(' ') + expect(response.body).to be_nil + end + end + + context 'JSON options' do + let(:body) { '{"a": 1}' } + let(:result) { { a: 1 } } + let(:options) do + { + parser_options: { + symbolize_names: true + } + } + end + + it 'passes relevant options to JSON parse' do + expect(::JSON).to receive(:parse) + .with(body, options[:parser_options]) + .and_return(result) + + response = process(body) + expect(response.body).to eq(result) + end + end +end