From 32dcce0cca0f6c8893d935874e98ecaa17ce0c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jok=C5=ABbas=20Pu=C4=8Dinskas?= Date: Thu, 31 Oct 2019 11:24:54 +0200 Subject: [PATCH 1/5] Add multipart requests support --- graphlient.gemspec | 1 + lib/graphlient/adapters/http.rb | 1 + .../http/faraday_multipart_adapter.rb | 42 ++++++++++++ .../format_multipart_variables.rb | 60 +++++++++++++++++ .../format_multipart_variables_spec.rb | 67 +++++++++++++++++++ .../http/faraday_multipart_adapter_spec.rb | 61 +++++++++++++++++ spec/support/fixtures/empty.txt | 0 7 files changed, 232 insertions(+) create mode 100644 lib/graphlient/adapters/http/faraday_multipart_adapter.rb create mode 100644 lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb create mode 100644 spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb create mode 100644 spec/graphlient/adapters/http/faraday_multipart_adapter_spec.rb create mode 100644 spec/support/fixtures/empty.txt diff --git a/graphlient.gemspec b/graphlient.gemspec index 81536cb..cb576d0 100644 --- a/graphlient.gemspec +++ b/graphlient.gemspec @@ -17,4 +17,5 @@ Gem::Specification.new do |s| s.add_dependency 'faraday' s.add_dependency 'faraday_middleware' s.add_dependency 'graphql-client' + s.add_dependency 'mime-types' end diff --git a/lib/graphlient/adapters/http.rb b/lib/graphlient/adapters/http.rb index c9d87e3..96a90ae 100644 --- a/lib/graphlient/adapters/http.rb +++ b/lib/graphlient/adapters/http.rb @@ -1,3 +1,4 @@ require_relative 'http/adapter' require_relative 'http/faraday_adapter' +require_relative 'http/faraday_multipart_adapter' require_relative 'http/http_adapter' diff --git a/lib/graphlient/adapters/http/faraday_multipart_adapter.rb b/lib/graphlient/adapters/http/faraday_multipart_adapter.rb new file mode 100644 index 0000000..bc048ce --- /dev/null +++ b/lib/graphlient/adapters/http/faraday_multipart_adapter.rb @@ -0,0 +1,42 @@ +require 'faraday' +require 'faraday_middleware' + +module Graphlient + module Adapters + module HTTP + class FaradayMultipartAdapter < Adapter + require 'graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables' + + def execute(document:, operation_name:, variables:, context:) + response = connection.post do |req| + req.headers.merge!(context[:headers] || {}) + req.body = { + query: document.to_query_string, + operationName: operation_name, + variables: FormatMultipartVariables.new(variables).call + } + end + + response.body + rescue Faraday::ClientError => e + raise Graphlient::Errors::FaradayServerError, e + end + + def connection + @connection ||= Faraday.new(url: url, headers: headers) do |c| + c.use Faraday::Response::RaiseError + c.request :multipart + c.request :url_encoded + c.response :json + + if block_given? + yield c + else + c.use Faraday::Adapter::NetHttp + end + end + end + end + end + end +end diff --git a/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb b/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb new file mode 100644 index 0000000..e5082a8 --- /dev/null +++ b/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb @@ -0,0 +1,60 @@ +require 'faraday' +require 'mime/types' +require 'graphlient/errors' + +module Graphlient + module Adapters + module HTTP + class FaradayMultipartAdapter + class NoMimeTypeException < Graphlient::Errors::Error; end + # Converts deeply nested File instances to Faraday::UploadIO + class FormatMultipartVariables + def initialize(variables) + @variables = variables + end + + def call + deep_transform_values(variables) do |variable| + variable_value(variable) + end + end + + private + + attr_reader :variables + + def deep_transform_values(hash, &block) + return hash unless hash.is_a?(Hash) + + hash.transform_values do |val| + if val.is_a?(Hash) + deep_transform_values(val, &block) + else + yield(val) + end + end + end + + def variable_value(variable) + if variable.is_a?(Array) + variable.map { |it| variable_value(it) } + elsif variable.is_a?(Hash) + variable.transform_values { |it| variable_value(it) } + elsif variable.is_a?(File) + file_variable_value(variable) + else + variable + end + end + + def file_variable_value(file) + content_type = MIME::Types.type_for(file.path).first + return Faraday::UploadIO.new(file.path, content_type) if content_type + + raise NoMimeTypeException, "Unable to determine mime type for #{file.path}" + end + end + end + end + end +end diff --git a/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb b/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb new file mode 100644 index 0000000..f9c4c53 --- /dev/null +++ b/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables' + +RSpec.describe Graphlient::Adapters::HTTP::FaradayMultipartAdapter::FormatMultipartVariables do + subject(:format_multipart_variables) { described_class.new(variables) } + + describe '#call' do + subject(:call) { format_multipart_variables.call } + + context 'when file does not have mime type' do + let(:variables) { { val: { file: File.new('/dev/null') } } } + + it 'raises an error' do + expect { call }.to raise_error(Graphlient::Adapters::HTTP::FaradayMultipartAdapter::NoMimeTypeException) + end + end + + context 'when variable is not a file' do + let(:variables) { { val: { name: 'John Doe' } } } + + it 'returns correct value' do + expect(call).to eq(variables) + end + end + + context 'when file is deeply nested' do + let(:variables) { { val: { file: File.new('spec/support/fixtures/empty.txt') } } } + + it 'contverts file to Faraday::UploadIO' do + expect(call[:val][:file]).to be_a(Faraday::UploadIO) + end + end + + context 'when files are in array' do + let(:variables) do + { + val: [ + File.new('spec/support/fixtures/empty.txt'), + File.new('spec/support/fixtures/empty.txt') + ] + } + end + + it 'contverts file to Faraday::UploadIO' do + expect(call[:val]).to all be_a(Faraday::UploadIO) + end + end + + context 'when file is in array and then nested' do + let(:variables) do + { + val: [ + { file: File.new('spec/support/fixtures/empty.txt') }, + { file: File.new('spec/support/fixtures/empty.txt') } + ] + } + end + + it 'contverts file to Faraday::UploadIO' do + result = call[:val].map { |val| val[:file] } + expect(result).to all be_a(Faraday::UploadIO) + end + end + end +end diff --git a/spec/graphlient/adapters/http/faraday_multipart_adapter_spec.rb b/spec/graphlient/adapters/http/faraday_multipart_adapter_spec.rb new file mode 100644 index 0000000..349d503 --- /dev/null +++ b/spec/graphlient/adapters/http/faraday_multipart_adapter_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Graphlient::Adapters::HTTP::FaradayMultipartAdapter do + let(:app) { Object.new } + + context 'with a custom middleware' do + let(:client) do + Graphlient::Client.new('http://example.com/graphql', http: described_class) do |client| + client.http do |h| + h.connection do |c| + c.use Faraday::Adapter::Rack, app + end + end + end + end + + it 'inserts a middleware into the connection' do + expect(client.http.connection.builder.handlers).to eq( + [ + Faraday::Response::RaiseError, + Faraday::Request::Multipart, + Faraday::Request::UrlEncoded, + FaradayMiddleware::ParseJson, + Faraday::Adapter::Rack + ] + ) + end + end + + context 'with custom url and headers' do + let(:url) { 'http://example.com/graphql' } + let(:headers) { { 'Foo' => 'bar' } } + let(:client) do + Graphlient::Client.new(url, headers: headers, http: described_class) + end + + it 'sets url' do + expect(client.http.url).to eq url + end + + it 'sets headers' do + expect(client.http.headers).to eq headers + end + end + + context 'default' do + let(:url) { 'http://example.com/graphql' } + let(:client) { Graphlient::Client.new(url, http: described_class) } + + before do + stub_request(:post, url).to_return( + status: 200, + body: DummySchema.execute(GraphQL::Introspection::INTROSPECTION_QUERY).to_json + ) + end + + it 'retrieves schema' do + expect(client.schema).to be_a Graphlient::Schema + end + end +end diff --git a/spec/support/fixtures/empty.txt b/spec/support/fixtures/empty.txt new file mode 100644 index 0000000..e69de29 From cc7096c2cddd04d58209ad70a037df3705494c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jok=C5=ABbas=20Pu=C4=8Dinskas?= Date: Thu, 31 Oct 2019 11:51:02 +0200 Subject: [PATCH 2/5] Add documentation for multipart request support --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 87162eb..7f473bb 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,21 @@ client.query(input: { fee_in_cents: 12_345 }) do end ``` +### Files support + +You can send files while using `FaradayMultipartAdapter` + +```ruby +client = Graphlient::Client.new('example.com/graphql', + http: Graphlient::Adapters::HTTP::FaradayMultipartAdapter +) + +file = File.read('example.txt') +client.mutation(input: file) # single file +client.mutation(input: [file]) # files as an array +client.mutation(input: [{ val: file }]) # files in nested hash +``` + ### Parse and Execute Queries Separately You can `parse` and `execute` queries separately with optional variables. This is highly recommended as parsing a query and validating a query on every request adds performance overhead. Parsing queries early allows validation errors to be discovered before request time and avoids many potential security issues. From 9783a3df4300d522cbf4a32d3ea51a2db6d3f9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jok=C5=ABbas=20Pu=C4=8Dinskas?= Date: Thu, 31 Oct 2019 13:11:46 +0200 Subject: [PATCH 3/5] Add rubocop_todo --- .rubocop_todo.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 530f19b..227f02d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-08-14 17:57:21 -0400 using RuboCop version 0.56.0. +# on 2019-10-31 13:05:23 +0200 using RuboCop version 0.56.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -15,11 +15,7 @@ Lint/UnusedMethodArgument: # Offense count: 2 Metrics/CyclomaticComplexity: - Max: 8 - -# Offense count: 1 -Metrics/PerceivedComplexity: - Max: 8 + Max: 7 # Offense count: 1 # Cop supports --auto-correct. @@ -35,13 +31,13 @@ Style/MethodMissingSuper: - 'lib/graphlient/extensions/query.rb' - 'lib/graphlient/query.rb' -# Offense count: 5 +# Offense count: 7 Style/MultilineBlockChain: Exclude: - 'spec/graphlient/client_query_spec.rb' - 'spec/graphlient/client_schema_spec.rb' -# Offense count: 39 +# Offense count: 54 # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. # URISchemes: http, https Metrics/LineLength: From 6ea240c047a3c1498c71b395f19ce2520ba9eaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jok=C5=ABbas=20Pu=C4=8Dinskas?= Date: Wed, 6 Nov 2019 13:57:54 +0200 Subject: [PATCH 4/5] Fix typo --- README.md | 2 +- .../format_multipart_variables_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f473bb..e199b77 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ end ### Files support -You can send files while using `FaradayMultipartAdapter` +You can send files while using `FaradayMultipartAdapter`. ```ruby client = Graphlient::Client.new('example.com/graphql', diff --git a/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb b/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb index f9c4c53..2d3dc33 100644 --- a/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb +++ b/spec/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables_spec.rb @@ -58,7 +58,7 @@ } end - it 'contverts file to Faraday::UploadIO' do + it 'converts file to Faraday::UploadIO' do result = call[:val].map { |val| val[:file] } expect(result).to all be_a(Faraday::UploadIO) end From 22f04929fd449fe4cd96a60d50ee3084fbb9b2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jok=C5=ABbas=20Pu=C4=8Dinskas?= Date: Wed, 6 Nov 2019 13:58:44 +0200 Subject: [PATCH 5/5] Do not use ruby2.4 "Hash#transform_values" method --- .../format_multipart_variables.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb b/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb index e5082a8..69810b0 100644 --- a/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb +++ b/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb @@ -26,7 +26,7 @@ def call def deep_transform_values(hash, &block) return hash unless hash.is_a?(Hash) - hash.transform_values do |val| + transform_hash_values(hash) do |val| if val.is_a?(Hash) deep_transform_values(val, &block) else @@ -39,7 +39,9 @@ def variable_value(variable) if variable.is_a?(Array) variable.map { |it| variable_value(it) } elsif variable.is_a?(Hash) - variable.transform_values { |it| variable_value(it) } + transform_hash_values(variable) do |value| + variable_value(value) + end elsif variable.is_a?(File) file_variable_value(variable) else @@ -53,6 +55,12 @@ def file_variable_value(file) raise NoMimeTypeException, "Unable to determine mime type for #{file.path}" end + + def transform_hash_values(hash) + hash.each_with_object({}) do |(key, value), result| + result[key] = yield(value) + end + end end end end