├── .github └── workflows │ └── specs.yml ├── .gitignore ├── .rspec ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── apollo_upload_server.gemspec ├── bin ├── console └── setup ├── lib ├── apollo_upload_server.rb └── apollo_upload_server │ ├── graphql_data_builder.rb │ ├── middleware.rb │ ├── railtie.rb │ ├── upload.rb │ ├── version.rb │ └── wrappers │ └── uploaded_file.rb └── spec ├── apollo_upload_server ├── graphql_data_builder_spec.rb ├── middleware_spec.rb └── upload_spec.rb └── spec_helper.rb /.github/workflows/specs.yml: -------------------------------------------------------------------------------- 1 | name: Rspec test suite 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby: ['2.7', '3.0', '3.1'] 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: ${{ matrix.ruby }} 15 | bundler-cache: true 16 | - run: bundle exec rspec -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /vendor/bundle/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ifuelen@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in apollo_upload_server.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | apollo_upload_server (2.1.5) 5 | actionpack (>= 6.1.6) 6 | graphql (>= 1.8) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionpack (7.0.4) 12 | actionview (= 7.0.4) 13 | activesupport (= 7.0.4) 14 | rack (~> 2.0, >= 2.2.0) 15 | rack-test (>= 0.6.3) 16 | rails-dom-testing (~> 2.0) 17 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 18 | actionview (7.0.4) 19 | activesupport (= 7.0.4) 20 | builder (~> 3.1) 21 | erubi (~> 1.4) 22 | rails-dom-testing (~> 2.0) 23 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 24 | activesupport (7.0.4) 25 | concurrent-ruby (~> 1.0, >= 1.0.2) 26 | i18n (>= 1.6, < 2) 27 | minitest (>= 5.1) 28 | tzinfo (~> 2.0) 29 | builder (3.2.4) 30 | concurrent-ruby (1.1.10) 31 | crass (1.0.6) 32 | diff-lcs (1.5.0) 33 | erubi (1.11.0) 34 | graphql (2.0.15) 35 | i18n (1.12.0) 36 | concurrent-ruby (~> 1.0) 37 | loofah (2.19.0) 38 | crass (~> 1.0.2) 39 | nokogiri (>= 1.5.9) 40 | mini_portile2 (2.8.0) 41 | minitest (5.16.3) 42 | nokogiri (1.13.9) 43 | mini_portile2 (~> 2.8.0) 44 | racc (~> 1.4) 45 | racc (1.6.0) 46 | rack (2.2.4) 47 | rack-test (2.0.2) 48 | rack (>= 1.3) 49 | rails-dom-testing (2.0.3) 50 | activesupport (>= 4.2.0) 51 | nokogiri (>= 1.6) 52 | rails-html-sanitizer (1.4.3) 53 | loofah (~> 2.3) 54 | rake (13.0.6) 55 | rspec (3.11.0) 56 | rspec-core (~> 3.11.0) 57 | rspec-expectations (~> 3.11.0) 58 | rspec-mocks (~> 3.11.0) 59 | rspec-core (3.11.0) 60 | rspec-support (~> 3.11.0) 61 | rspec-expectations (3.11.0) 62 | diff-lcs (>= 1.2.0, < 2.0) 63 | rspec-support (~> 3.11.0) 64 | rspec-mocks (3.11.1) 65 | diff-lcs (>= 1.2.0, < 2.0) 66 | rspec-support (~> 3.11.0) 67 | rspec-support (3.11.0) 68 | tzinfo (2.0.5) 69 | concurrent-ruby (~> 1.0) 70 | 71 | PLATFORMS 72 | ruby 73 | 74 | DEPENDENCIES 75 | apollo_upload_server! 76 | bundler (~> 2.1) 77 | rake (~> 13.0) 78 | rspec (~> 3.11) 79 | 80 | BUNDLED WITH 81 | 2.3.17 82 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Artur Plysyuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ApolloUploadServer 2 | 3 | Middleware which allows you to upload files using [graphql-ruby](https://github.com/rmosolgo/graphql-ruby), [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) and Ruby on Rails. 4 | 5 | Note: this implementation uses [v2 of the GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2), so you should use apollo-upload-client library >= v7.0.0-alpha.3. If you need support for [v1 of the GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v1.0.0), you must 6 | use [version 1.0.0](https://github.com/jetruby/apollo_upload_server-ruby/tree/1.0.0) of this gem. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'apollo_upload_server', '2.1' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install apollo_upload_server 23 | 24 | Middleware will be used automatically. 25 | 26 | Gem adds custom `Upload` type to your GraphQL types. 27 | Use `ApolloUploadServer::Upload` type for your file as input field: 28 | 29 | ```ruby 30 | input_field :file, ApolloUploadServer::Upload 31 | ``` 32 | 33 | That's all folks! 34 | 35 | ## Configuration 36 | 37 | The following configuration options are supported: 38 | 39 | ### Strict Mode 40 | 41 | This can be set on `ApolloUploadServer::Middleware`: 42 | 43 | ```ruby 44 | ApolloUploadServer::Middleware.strict_mode = true 45 | ``` 46 | 47 | Doing so ensures that all mapped array values are present in the input. If this 48 | is set to `true`, then for following request: 49 | 50 | ```json 51 | { 52 | "operations": { 53 | "query": "mutation { ... }", 54 | "operationName": "SomeOperation", 55 | "variables": { 56 | "input": { "id": "123", "avatars": [null, null] } 57 | } 58 | } 59 | } 60 | ``` 61 | 62 | A mapping for `variables.input.avatars.0` or `variables.input.avatars.1`, will work, but one for 63 | `variables.input.avatars.100` will not, and will raise an error. 64 | 65 | In strict mode, passing empty destination arrays will always fail. 66 | 67 | ## Contributing 68 | 69 | Bug reports and pull requests are welcome on GitHub at https://github.com/jetruby/apollo_upload_server-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 70 | 71 | Tests can be run via `bundle exec rspec`. 72 | 73 | ## License 74 | 75 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 76 | 77 | ## Code of Conduct 78 | 79 | Everyone interacting in the ApolloUploadServer project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jetruby/apollo_upload_server-ruby/blob/master/CODE_OF_CONDUCT.md). 80 | 81 | ## About JetRuby 82 | 83 | ApolloUploadServer is maintained and founded by JetRuby Agency. 84 | 85 | We love open source software! 86 | See [our projects][portfolio] or 87 | [contact us][contact] to design, develop, and grow your product. 88 | 89 | [portfolio]: http://jetruby.com/portfolio/ 90 | [contact]: http://jetruby.com/#contactUs 91 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | task default: :spec 3 | -------------------------------------------------------------------------------- /apollo_upload_server.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'apollo_upload_server/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'apollo_upload_server' 7 | spec.version = ApolloUploadServer::VERSION 8 | spec.authors = ['JetRuby'] 9 | spec.email = ['engineering@jetruby.com'] 10 | 11 | spec.summary = 'Middleware which allows you to upload files using graphql and multipart/form-data.' 12 | spec.description = 'apollo-upload-server implementation for Ruby on Rails as middleware.' 13 | spec.homepage = 'https://github.com/jetruby/apollo_upload_server-ruby' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'actionpack', '>= 6.1.6' 22 | spec.add_dependency 'graphql', '>= 1.8' 23 | 24 | spec.add_development_dependency 'bundler', '~> 2.1' 25 | spec.add_development_dependency 'rake', '~> 13.0' 26 | spec.add_development_dependency 'rspec', '~> 3.11' 27 | end 28 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'apollo_upload_server' 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require 'irb' 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/apollo_upload_server.rb: -------------------------------------------------------------------------------- 1 | require 'apollo_upload_server/graphql_data_builder' 2 | require 'apollo_upload_server/middleware' 3 | require 'apollo_upload_server/version' 4 | require 'apollo_upload_server/railtie' 5 | require 'apollo_upload_server/upload' 6 | -------------------------------------------------------------------------------- /lib/apollo_upload_server/graphql_data_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'apollo_upload_server/wrappers/uploaded_file' 5 | 6 | module ApolloUploadServer 7 | class GraphQLDataBuilder 8 | OutOfBounds = Class.new(ArgumentError) 9 | 10 | def initialize(strict_mode: false) 11 | @strict_mode = strict_mode 12 | end 13 | 14 | def call(params) 15 | operations = JSON.parse(params['operations']) 16 | file_mapper = JSON.parse(params['map']) 17 | 18 | return nil if operations.nil? || file_mapper.nil? 19 | if operations.is_a?(Hash) 20 | single_transformation(file_mapper, operations, params) 21 | else 22 | { '_json' => multiple_transformation(file_mapper, operations, params) } 23 | end 24 | end 25 | 26 | private 27 | 28 | def single_transformation(file_mapper, operations, params) 29 | operations = operations.dup 30 | file_mapper.each do |file_index, paths| 31 | paths.each do |path| 32 | splited_path = path.split('.') 33 | # splited_path => 'variables.input.profile_photo'; splited_path[0..-2] => ['variables', 'input'] 34 | # dig from first to penultimate key, and merge last key with value as file 35 | 36 | field = get_parent_field(operations, splited_path) 37 | assign_file(field, splited_path, params[file_index]) 38 | end 39 | end 40 | operations 41 | end 42 | 43 | def multiple_transformation(file_mapper, operations, params) 44 | operations = operations.dup 45 | 46 | file_mapper.each do |file_index, paths| 47 | paths.each do |path| 48 | splited_path = path.split('.') 49 | # dig from second to penultimate key, and merge last key with value as file to operation with first key index 50 | field = operations[splited_path.first.to_i].dig(*splited_path[1..-2]) 51 | 52 | assign_file(field, splited_path, params[file_index]) 53 | end 54 | end 55 | operations 56 | end 57 | 58 | def verify_array_index!(path, index, size) 59 | return unless @strict_mode 60 | return if 0 <= index && index < size 61 | 62 | raise OutOfBounds, "Path #{path.join('.')} maps to out-of-bounds index: #{index}" 63 | end 64 | 65 | def get_parent_field(operations, splited_path) 66 | # returns parent element of file field 67 | 68 | splited_path[0..-2].inject(operations) do |element, key| 69 | case element 70 | when Array 71 | element[Integer(key)] 72 | else 73 | element[key] 74 | end 75 | end 76 | end 77 | 78 | 79 | def assign_file(field, splited_path, file) 80 | wrapped_file = Wrappers::UploadedFile.new(file) 81 | 82 | if field.is_a? Hash 83 | field.merge!(splited_path.last => wrapped_file) 84 | elsif field.is_a? Array 85 | index = parse_array_index(splited_path) 86 | verify_array_index!(splited_path, index, field.size) 87 | field[index] = wrapped_file 88 | end 89 | end 90 | 91 | def parse_array_index(path) 92 | return path.last.to_i unless @strict_mode 93 | 94 | Integer(path.last) 95 | rescue ArgumentError 96 | raise OutOfBounds, "Not a valid path to an array value: #{path.join('.')}" 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/apollo_upload_server/middleware.rb: -------------------------------------------------------------------------------- 1 | require 'apollo_upload_server/graphql_data_builder' 2 | require "active_support/configurable" 3 | 4 | module ApolloUploadServer 5 | class Middleware 6 | include ActiveSupport::Configurable 7 | 8 | # Strict mode requires that all mapped files are present in the mapping arrays. 9 | config_accessor :strict_mode do 10 | false 11 | end 12 | 13 | def initialize(app) 14 | @app = app 15 | end 16 | 17 | def call(env) 18 | unless env['CONTENT_TYPE'].to_s.include?('multipart/form-data') 19 | return @app.call(env) 20 | end 21 | 22 | request = ActionDispatch::Request.new(env) 23 | params = request.params 24 | 25 | if params['operations'].present? && params['map'].present? 26 | result = GraphQLDataBuilder.new(strict_mode: self.class.strict_mode).call(request.params) 27 | result&.each do |key, value| 28 | request.update_param(key, value) 29 | end 30 | end 31 | 32 | @app.call(env) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/apollo_upload_server/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'apollo_upload_server/middleware' 2 | 3 | module ApolloUploadServer 4 | class Railtie < Rails::Railtie 5 | initializer 'apollo_upload_server.apply_middleware' do |app| 6 | app.middleware.use Middleware 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/apollo_upload_server/upload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql' 4 | 5 | module ApolloUploadServer 6 | class Upload < GraphQL::Schema::Scalar 7 | graphql_name "Upload" 8 | 9 | def self.coerce_input(value, _ctx) 10 | return super if value.nil? || value.is_a?(::ApolloUploadServer::Wrappers::UploadedFile) 11 | 12 | raise GraphQL::CoercionError, "#{value.inspect} is not a valid upload" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/apollo_upload_server/version.rb: -------------------------------------------------------------------------------- 1 | module ApolloUploadServer 2 | VERSION = '2.1.6'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/apollo_upload_server/wrappers/uploaded_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'delegate' 4 | require 'action_dispatch/http/upload' 5 | 6 | module ApolloUploadServer 7 | module Wrappers 8 | class UploadedFile < DelegateClass(::ActionDispatch::Http::UploadedFile) 9 | def initialize(wrapped_foo) 10 | super 11 | end 12 | 13 | def as_json(options = nil) 14 | instance_values.except('tempfile').as_json(options) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/apollo_upload_server/graphql_data_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'apollo_upload_server/graphql_data_builder' 3 | 4 | describe ApolloUploadServer::GraphQLDataBuilder do 5 | describe '#call for single operation' do 6 | let(:params) do 7 | { 8 | 'operations' => { 9 | 'query' => 'mutation { blah blah }', 10 | 'operationName' => 'SomeOperation', 11 | 'variables' => { 'input' => { 'id' => '123', 'model' => {} } } 12 | }.to_json, 13 | 'map' => { '0' => ['variables.input.avatar', 'variables.input.model.avatar'] }.to_json, 14 | '0' => :file0 15 | } 16 | end 17 | 18 | let(:expected_params) do 19 | { 20 | 'query' => 'mutation { blah blah }', 21 | 'operationName' => 'SomeOperation', 22 | 'variables' => { 'input' => { 'id' => '123', 'avatar' => :file0, 'model' => { 'avatar' => :file0 } } } 23 | } 24 | end 25 | 26 | specify do 27 | expect(described_class.new.call(params)).to eq(expected_params) 28 | end 29 | end 30 | 31 | describe '#call for single operation with multiple files' do 32 | let(:params) do 33 | { 34 | 'operations' => { 35 | 'query' => 'mutation { blah blah }', 36 | 'operationName' => 'SomeOperation', 37 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [nil], 'model' => { 'avatars' => [nil] } } } 38 | }.to_json, 39 | 'map' => { '0' => ['variables.input.avatars.0', 'variables.input.model.avatars.0'] }.to_json, 40 | '0' => :file0 41 | } 42 | end 43 | 44 | let(:expected_params) do 45 | { 46 | 'query' => 'mutation { blah blah }', 47 | 'operationName' => 'SomeOperation', 48 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [:file0], 'model' => { 'avatars' => [:file0] } } } 49 | } 50 | end 51 | 52 | specify do 53 | expect(described_class.new.call(params)).to eq(expected_params) 54 | end 55 | 56 | context 'when the index is not a string' do 57 | let(:params) do 58 | { 59 | 'operations' => { 60 | 'query' => 'mutation { blah blah }', 61 | 'operationName' => 'SomeOperation', 62 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [nil], 'model' => { 'avatars' => [nil] } } } 63 | }.to_json, 64 | 'map' => { '0' => ['variables.input.avatars.foo', 'variables.input.model.avatars.0'] }.to_json, 65 | '0' => :file0 66 | } 67 | end 68 | 69 | specify do 70 | expect(described_class.new.call(params)).to eq(expected_params) 71 | end 72 | 73 | it 'is rejected in strict mode' do 74 | expect do 75 | described_class.new(strict_mode: true).call(params) 76 | end.to raise_error(described_class::OutOfBounds) 77 | end 78 | end 79 | 80 | context 'when the array is empty' do 81 | let(:params) do 82 | { 83 | 'operations' => { 84 | 'query' => 'mutation { blah blah }', 85 | 'operationName' => 'SomeOperation', 86 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [], 'model' => { 'avatars' => [nil] } } } 87 | }.to_json, 88 | 'map' => { '0' => ['variables.input.avatars.0', 'variables.input.model.avatars.0'] }.to_json, 89 | '0' => :file0 90 | } 91 | end 92 | 93 | let(:expected_params) do 94 | { 95 | 'query' => 'mutation { blah blah }', 96 | 'operationName' => 'SomeOperation', 97 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [:file0], 'model' => { 'avatars' => [:file0] } } } 98 | } 99 | end 100 | 101 | specify do 102 | expect(described_class.new.call(params)).to eq(expected_params) 103 | end 104 | 105 | it 'accepts this input in lax mode' do 106 | expect(described_class.new.call(params)).to eq(expected_params) 107 | end 108 | 109 | it 'rejects this input in strict mode' do 110 | expect do 111 | described_class.new(strict_mode: true).call(params) 112 | end.to raise_error(described_class::OutOfBounds) 113 | end 114 | end 115 | end 116 | 117 | describe '#call for multiple operations' do 118 | let(:params) do 119 | { 120 | 'operations' => [{ 121 | 'query' => 'mutation { blah blah1 }', 122 | 'operationName' => nil, 123 | 'variables' => { 'input' => { 'id' => '123' } } 124 | }, 125 | { 126 | 'query' => 'mutation { blah blah2 }', 127 | 'operationName' => 'hashKeyCzaza', 128 | 'variables' => { 'input' => { 'id' => '123' } } 129 | }, 130 | { 131 | 'query' => 'mutation { blah blah3 }', 132 | 'operationName' => 'Some_Operation', 133 | 'hashKeyA' => { 'hashKeyB' => { 'id' => '123' } } 134 | }].to_json, 135 | 'map' => { '0' => ['0.variables.input.avatar', '2.hashKeyA.hashKeyB.hashKeyC'] }.to_json, 136 | '0' => :file0 137 | } 138 | end 139 | 140 | let(:expected_params) do 141 | {'_json' => [ 142 | { 143 | 'query' => 'mutation { blah blah1 }', 144 | 'operationName' => nil, 145 | 'variables' => { 'input' => { 'id' => '123', 'avatar' => :file0 } } 146 | }, 147 | { 148 | 'query' => 'mutation { blah blah2 }', 149 | 'operationName' => 'hashKeyCzaza', 150 | 'variables' => { 'input' => { 'id' => '123' } } 151 | }, 152 | { 153 | 'query' => 'mutation { blah blah3 }', 154 | 'operationName' => 'Some_Operation', 155 | 'hashKeyA' => { 'hashKeyB' => { 'id' => '123', 'hashKeyC' => :file0 } } 156 | } 157 | ]} 158 | end 159 | 160 | specify do 161 | expect(described_class.new.call(params)).to eq(expected_params) 162 | end 163 | end 164 | 165 | describe '#call for multiple operations and many files' do 166 | let(:params) do 167 | { 168 | 'operations' => [{ 169 | 'query' => 'mutation { blah blah1 }', 170 | 'operationName' => nil, 171 | 'variables' => { 'input' => { 'id' => '123' } } 172 | }, 173 | { 174 | 'query' => 'mutation { blah blah2 }', 175 | 'operationName' => 'hashKeyCzaza', 176 | 'variables' => { 'input' => { 'id' => '123' } } 177 | }, 178 | { 179 | 'query' => 'mutation { blah blah3 }', 180 | 'operationName' => 'Some_Operation', 181 | 'hashKeyA' => { 'hashKeyB' => { 'id' => '123', 'model' => { 'id' => '23' } } } 182 | }].to_json, 183 | 'map' => { '0' => ['0.variables.input.avatar', '2.hashKeyA.hashKeyB.hashKeyC', '2.hashKeyA.hashKeyB.file0'], 184 | '2' => ['0.variables.input.file2', '1.variables.input.profile_photo', '2.hashKeyA.hashKeyB.model.photo'] }.to_json, 185 | '0' => :file0, 186 | '1' => :file1, 187 | '2' => :file2 188 | } 189 | end 190 | 191 | let(:expected_params) do 192 | {'_json' => [ 193 | { 194 | 'query' => 'mutation { blah blah1 }', 195 | 'operationName' => nil, 196 | 'variables' => { 'input' => { 'id' => '123', 'avatar' => :file0, 'file2' => :file2 } } 197 | }, 198 | { 199 | 'query' => 'mutation { blah blah2 }', 200 | 'operationName' => 'hashKeyCzaza', 201 | 'variables' => { 'input' => { 'id' => '123', 'profile_photo' => :file2 } } 202 | }, 203 | { 204 | 'query' => 'mutation { blah blah3 }', 205 | 'operationName' => 'Some_Operation', 206 | 'hashKeyA' => { 'hashKeyB' => { 'id' => '123', 'model' => { 'id' => '23', 'photo' => :file2 }, 'hashKeyC' => :file0, 'file0' => :file0 } } 207 | } 208 | ] } 209 | end 210 | 211 | specify do 212 | expect(described_class.new.call(params)).to eq(expected_params) 213 | end 214 | end 215 | 216 | describe '#call for multiple operations with multiple files' do 217 | let(:params) do 218 | { 219 | 'operations' => [{ 220 | 'query' => 'mutation { blah blah1 }', 221 | 'operationName' => nil, 222 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [nil] } } 223 | }, 224 | { 225 | 'query' => 'mutation { blah blah2 }', 226 | 'operationName' => 'hashKeyCzaza', 227 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [nil] } } 228 | } 229 | ].to_json, 230 | 'map' => { '0' => ['0.variables.input.avatars.0', '1.variables.input.avatars.0'] }.to_json, 231 | '0' => :file0 232 | } 233 | end 234 | 235 | let(:expected_params) do 236 | {'_json' => [ 237 | { 238 | 'query' => 'mutation { blah blah1 }', 239 | 'operationName' => nil, 240 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [:file0] } } 241 | }, 242 | { 243 | 'query' => 'mutation { blah blah2 }', 244 | 'operationName' => 'hashKeyCzaza', 245 | 'variables' => { 'input' => { 'id' => '123', 'avatars' => [:file0] } } 246 | } 247 | ]} 248 | end 249 | 250 | specify do 251 | expect(described_class.new.call(params)).to eq(expected_params) 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/apollo_upload_server/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'action_dispatch' 3 | require 'apollo_upload_server/middleware' 4 | 5 | describe ApolloUploadServer::Middleware do 6 | around do |example| 7 | mode = described_class.strict_mode 8 | example.run 9 | described_class.strict_mode = mode 10 | end 11 | 12 | describe '#call' do 13 | let(:app) do 14 | Rack::Builder.new do 15 | use ApolloUploadServer::Middleware 16 | run ->(_env) { [200, { 'Content-Type' => 'text/plain' }, 'Hello, World.'] } 17 | end 18 | end 19 | 20 | context "when CONTENT_TYPE is 'multipart/form-data'" do 21 | subject do 22 | Rack::MockRequest.new(app).post('/', { 'CONTENT_TYPE' => 'multipart/form-data', input: 'operations={}&map={}' }) 23 | end 24 | 25 | it { expect(subject.status).to eq(200) } 26 | end 27 | 28 | context "when CONTENT_TYPE is not 'multipart/form-data'" do 29 | subject do 30 | Rack::MockRequest.new(app).post('/', { 'CONTENT_TYPE' => 'text/plain' }) 31 | end 32 | 33 | it { expect(subject.status).to eq(200) } 34 | end 35 | 36 | context 'when configured to run in strict mode' do 37 | before do 38 | described_class.strict_mode = true 39 | end 40 | 41 | subject do 42 | Rack::MockRequest.new(app).post('/', { 'CONTENT_TYPE' => 'multipart/form-data', input: 'operations={}&map={}' }) 43 | end 44 | 45 | it 'propagates this setting to the data builder' do 46 | expect(ApolloUploadServer::GraphQLDataBuilder).to receive(:new).with(strict_mode: true).and_call_original 47 | 48 | subject 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/apollo_upload_server/upload_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'apollo_upload_server/upload' 3 | 4 | RSpec.describe ApolloUploadServer::Upload do 5 | let(:ctx) { {} } 6 | 7 | describe '#coerce_input' do 8 | let(:uploaded_file) { ApolloUploadServer::Wrappers::UploadedFile.new('test') } 9 | 10 | specify do 11 | expect(described_class.coerce_input(uploaded_file, ctx)).to eq(uploaded_file) 12 | expect { described_class.coerce_input('test', ctx) }.to raise_error(GraphQL::CoercionError) 13 | expect(described_class.coerce_input(nil, ctx)).to eq(nil) 14 | end 15 | end 16 | 17 | describe '#coerce_result' do 18 | it { expect(described_class.coerce_result('test', ctx)).to eq 'test' } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.expect_with :rspec do |expectations| 3 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 4 | end 5 | 6 | config.mock_with :rspec do |mocks| 7 | mocks.verify_partial_doubles = true 8 | end 9 | 10 | config.shared_context_metadata_behavior = :apply_to_host_groups 11 | end 12 | --------------------------------------------------------------------------------