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: diff --git a/README.md b/README.md index 87162eb..e199b77 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. 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..69810b0 --- /dev/null +++ b/lib/graphlient/adapters/http/faraday_multipart_adapter/format_multipart_variables.rb @@ -0,0 +1,68 @@ +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) + + transform_hash_values(hash) 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) + transform_hash_values(variable) do |value| + variable_value(value) + end + 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 + + def transform_hash_values(hash) + hash.each_with_object({}) do |(key, value), result| + result[key] = yield(value) + end + 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..2d3dc33 --- /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 'converts 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