├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .rspec ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── graphql-extras.gemspec ├── lib └── graphql │ ├── extras.rb │ └── extras │ ├── controller.rb │ ├── preload.rb │ ├── test.rb │ ├── test │ ├── loader.rb │ ├── response.rb │ └── schema.rb │ ├── types.rb │ └── version.rb └── spec ├── fixtures ├── files │ └── image.jpg └── graphql │ ├── explode.graphql │ ├── hello.graphql │ ├── people.graphql │ ├── person.graphql │ └── upload_image.graphql ├── graphql ├── extras │ ├── controller_spec.rb │ ├── preload_spec.rb │ ├── test │ │ ├── loader_spec.rb │ │ └── schema_spec.rb │ └── types_spec.rb └── extras_spec.rb ├── spec_helper.rb └── support ├── database.rb ├── rails.rb └── schema.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | 10 | - name: Setup Ruby 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: 3.4.1 14 | 15 | - name: Install packages 16 | run: sudo apt-get install libsqlite3-dev 17 | 18 | - name: Install bundler 19 | run: gem install bundler 20 | 21 | - name: Install dependencies 22 | run: bundle install 23 | 24 | - name: Test 25 | run: bundle exec rspec 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup Ruby 13 | uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: 3.4.1 16 | 17 | - name: Install packages 18 | run: sudo apt-get install libsqlite3-dev 19 | 20 | - name: Install bundler 21 | run: gem install bundler 22 | 23 | - name: Install dependencies 24 | run: bundle install 25 | 26 | - name: Test 27 | run: bundle exec rspec 28 | 29 | - name: Set version 30 | run: perl -pi -e "s/0\.0\.0/${GITHUB_REF:11}/" lib/graphql/extras/version.rb 31 | 32 | - name: Publish 33 | run: | 34 | mkdir -p $HOME/.gem 35 | touch $HOME/.gem/credentials 36 | chmod 0600 $HOME/.gem/credentials 37 | printf -- "---\n:rubygems_api_key: ${RUBYGEMS_TOKEN}\n" > $HOME/.gem/credentials 38 | gem build *.gemspec 39 | gem push *.gem 40 | env: 41 | RUBYGEMS_TOKEN: ${{ secrets.RUBYGEMS_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in graphql-extras.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | graphql-extras (0.0.0) 5 | activesupport (>= 5.2) 6 | graphql (>= 1.12, < 3) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionpack (8.0.1) 12 | actionview (= 8.0.1) 13 | activesupport (= 8.0.1) 14 | nokogiri (>= 1.8.5) 15 | rack (>= 2.2.4) 16 | rack-session (>= 1.0.1) 17 | rack-test (>= 0.6.3) 18 | rails-dom-testing (~> 2.2) 19 | rails-html-sanitizer (~> 1.6) 20 | useragent (~> 0.16) 21 | actionview (8.0.1) 22 | activesupport (= 8.0.1) 23 | builder (~> 3.1) 24 | erubi (~> 1.11) 25 | rails-dom-testing (~> 2.2) 26 | rails-html-sanitizer (~> 1.6) 27 | activemodel (8.0.1) 28 | activesupport (= 8.0.1) 29 | activerecord (8.0.1) 30 | activemodel (= 8.0.1) 31 | activesupport (= 8.0.1) 32 | timeout (>= 0.4.0) 33 | activesupport (8.0.1) 34 | base64 35 | benchmark (>= 0.3) 36 | bigdecimal 37 | concurrent-ruby (~> 1.0, >= 1.3.1) 38 | connection_pool (>= 2.2.5) 39 | drb 40 | i18n (>= 1.6, < 2) 41 | logger (>= 1.4.2) 42 | minitest (>= 5.1) 43 | securerandom (>= 0.3) 44 | tzinfo (~> 2.0, >= 2.0.5) 45 | uri (>= 0.13.1) 46 | base64 (0.2.0) 47 | benchmark (0.4.0) 48 | bigdecimal (3.1.9) 49 | builder (3.3.0) 50 | concurrent-ruby (1.3.5) 51 | connection_pool (2.5.0) 52 | crass (1.0.6) 53 | date (3.4.1) 54 | diff-lcs (1.5.1) 55 | drb (2.2.1) 56 | erubi (1.13.1) 57 | fiber-storage (1.0.0) 58 | graphql (2.4.9) 59 | base64 60 | fiber-storage 61 | logger 62 | i18n (1.14.7) 63 | concurrent-ruby (~> 1.0) 64 | io-console (0.8.0) 65 | irb (1.15.1) 66 | pp (>= 0.6.0) 67 | rdoc (>= 4.0.0) 68 | reline (>= 0.4.2) 69 | logger (1.6.5) 70 | loofah (2.24.0) 71 | crass (~> 1.0.2) 72 | nokogiri (>= 1.12.0) 73 | minitest (5.25.4) 74 | nokogiri (1.18.2-aarch64-linux-gnu) 75 | racc (~> 1.4) 76 | nokogiri (1.18.2-aarch64-linux-musl) 77 | racc (~> 1.4) 78 | nokogiri (1.18.2-arm-linux-gnu) 79 | racc (~> 1.4) 80 | nokogiri (1.18.2-arm-linux-musl) 81 | racc (~> 1.4) 82 | nokogiri (1.18.2-arm64-darwin) 83 | racc (~> 1.4) 84 | nokogiri (1.18.2-x86_64-darwin) 85 | racc (~> 1.4) 86 | nokogiri (1.18.2-x86_64-linux-gnu) 87 | racc (~> 1.4) 88 | nokogiri (1.18.2-x86_64-linux-musl) 89 | racc (~> 1.4) 90 | pp (0.6.2) 91 | prettyprint 92 | prettyprint (0.2.0) 93 | psych (5.2.3) 94 | date 95 | stringio 96 | racc (1.8.1) 97 | rack (3.1.9) 98 | rack-session (2.1.0) 99 | base64 (>= 0.1.0) 100 | rack (>= 3.0.0) 101 | rack-test (2.2.0) 102 | rack (>= 1.3) 103 | rackup (2.2.1) 104 | rack (>= 3) 105 | rails-dom-testing (2.2.0) 106 | activesupport (>= 5.0.0) 107 | minitest 108 | nokogiri (>= 1.6) 109 | rails-html-sanitizer (1.6.2) 110 | loofah (~> 2.21) 111 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 112 | railties (8.0.1) 113 | actionpack (= 8.0.1) 114 | activesupport (= 8.0.1) 115 | irb (~> 1.13) 116 | rackup (>= 1.0.0) 117 | rake (>= 12.2) 118 | thor (~> 1.0, >= 1.2.2) 119 | zeitwerk (~> 2.6) 120 | rake (13.2.1) 121 | rdoc (6.11.0) 122 | psych (>= 4.0.0) 123 | reline (0.6.0) 124 | io-console (~> 0.5) 125 | rspec (3.13.0) 126 | rspec-core (~> 3.13.0) 127 | rspec-expectations (~> 3.13.0) 128 | rspec-mocks (~> 3.13.0) 129 | rspec-core (3.13.2) 130 | rspec-support (~> 3.13.0) 131 | rspec-expectations (3.13.3) 132 | diff-lcs (>= 1.2.0, < 2.0) 133 | rspec-support (~> 3.13.0) 134 | rspec-mocks (3.13.2) 135 | diff-lcs (>= 1.2.0, < 2.0) 136 | rspec-support (~> 3.13.0) 137 | rspec-rails (7.1.0) 138 | actionpack (>= 7.0) 139 | activesupport (>= 7.0) 140 | railties (>= 7.0) 141 | rspec-core (~> 3.13) 142 | rspec-expectations (~> 3.13) 143 | rspec-mocks (~> 3.13) 144 | rspec-support (~> 3.13) 145 | rspec-support (3.13.2) 146 | securerandom (0.4.1) 147 | sqlite3 (2.5.0-aarch64-linux-gnu) 148 | sqlite3 (2.5.0-aarch64-linux-musl) 149 | sqlite3 (2.5.0-arm-linux-gnu) 150 | sqlite3 (2.5.0-arm-linux-musl) 151 | sqlite3 (2.5.0-arm64-darwin) 152 | sqlite3 (2.5.0-x86_64-darwin) 153 | sqlite3 (2.5.0-x86_64-linux-gnu) 154 | sqlite3 (2.5.0-x86_64-linux-musl) 155 | stringio (3.1.2) 156 | thor (1.3.2) 157 | timeout (0.4.3) 158 | tzinfo (2.0.6) 159 | concurrent-ruby (~> 1.0) 160 | uri (1.0.2) 161 | useragent (0.16.11) 162 | zeitwerk (2.7.1) 163 | 164 | PLATFORMS 165 | aarch64-linux 166 | aarch64-linux-gnu 167 | aarch64-linux-musl 168 | arm-linux 169 | arm-linux-gnu 170 | arm-linux-musl 171 | arm64-darwin 172 | x86_64-darwin 173 | x86_64-linux 174 | x86_64-linux-gnu 175 | x86_64-linux-musl 176 | 177 | DEPENDENCIES 178 | actionpack 179 | activerecord 180 | bundler 181 | graphql-extras! 182 | rake 183 | rspec 184 | rspec-rails 185 | sqlite3 186 | 187 | BUNDLED WITH 188 | 2.6.3 189 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2025 Ray Zane 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

GraphQL::Extras

2 | 3 |
4 | 5 | ![Build](https://github.com/rzane/graphql-extras/workflows/Build/badge.svg) 6 | ![Version](https://img.shields.io/gem/v/graphql-extras) 7 | 8 |
9 | 10 | A collection of utilities for building GraphQL APIs. 11 | 12 | **Table of Contents** 13 | 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | - [GraphQL::Extras::Controller](#graphqlextrascontroller) 17 | - [GraphQL::Extras::Preload](#graphqlextraspreload) 18 | - [GraphQL::Extras::PreloadSource](#graphqlextraspreloadsource) 19 | - [GraphQL::Extras::Types](#graphqlextrastypes) 20 | - [Date](#date) 21 | - [DateTime](#datetime) 22 | - [Decimal](#decimal) 23 | - [Upload](#upload) 24 | - [GraphQL::Extras::Test](#graphqlextrastest) 25 | - [Development](#development) 26 | - [Contributing](#contributing) 27 | 28 | ## Installation 29 | 30 | Add this line to your application's Gemfile: 31 | 32 | ```ruby 33 | gem 'graphql-extras' 34 | ``` 35 | 36 | And then execute: 37 | 38 | $ bundle 39 | 40 | ## Usage 41 | 42 | ### GraphQL::Extras::Controller 43 | 44 | The [`graphql` gem](https://github.com/rmosolgo/graphql-ruby) will generate a controller for you with a bunch of boilerplate. This module will encapsulate that boilerplate: 45 | 46 | ```ruby 47 | class GraphqlController < ApplicationController 48 | include GraphQL::Extras::Controller 49 | 50 | def execute 51 | graphql(schema: MySchema, context: { current_user: current_user }) 52 | end 53 | end 54 | ``` 55 | 56 | ### GraphQL::Extras::Preload 57 | 58 | This allows you to preload associations before resolving fields. 59 | 60 | ```ruby 61 | class Schema < GraphQL::Schema 62 | use GraphQL::Dataloader 63 | end 64 | 65 | class BaseField < GraphQL::Schema::Field 66 | prepend GraphQL::Extras::Preload 67 | end 68 | 69 | class BaseObject < GraphQL::Schema::Object 70 | field_class BaseField 71 | end 72 | 73 | class PostType < BaseObject 74 | field :author, AuthorType, preload: :author, null: false 75 | field :author_posts, [PostType], preload: {author: :posts}, null: false 76 | field :depends_on_author, Integer, preload: :author, null: false 77 | 78 | def author_posts 79 | object.author.posts 80 | end 81 | end 82 | ``` 83 | 84 | ### GraphQL::Extras::PreloadSource 85 | 86 | This is a subclass of [`GraphQL::Dataloader::Source`](https://graphql-ruby.org/dataloader/overview.html) that performs eager loading of Active Record associations. 87 | 88 | ```ruby 89 | loader = dataloader.with(GraphQL::Extras::PreloadSource, :blog) 90 | loader.load(Post.first) 91 | loader.load_many(Post.all) 92 | ``` 93 | 94 | ### GraphQL::Extras::Types 95 | 96 | In your base classes, you should include the `GraphQL::Extras::Types`. 97 | 98 | ```ruby 99 | class BaseObject < GraphQL::Schema::Object 100 | include GraphQL::Extras::Types 101 | end 102 | 103 | class BaseInputObject < GraphQL::Schema::InputObject 104 | include GraphQL::Extras::Types 105 | end 106 | ``` 107 | 108 | #### Date 109 | 110 | This scalar takes a `Date` and transmits it as a string, using ISO 8601 format. 111 | 112 | ```ruby 113 | field :birthday, Date, required: true 114 | ``` 115 | 116 | #### DateTime 117 | 118 | This scalar takes a `DateTime` and transmits it as a string, using ISO 8601 format. 119 | 120 | ```ruby 121 | field :created_at, DateTime, required: true 122 | ``` 123 | 124 | _Note: This is just an alias for the `ISO8601DateTime` type that is included in the `graphql` gem._ 125 | 126 | #### Decimal 127 | 128 | This scalar takes a `BigDecimal` and transmits it as a string. 129 | 130 | ```ruby 131 | field :weight, BigDecimal, required: true 132 | ``` 133 | 134 | #### Upload 135 | 136 | This scalar is used for accepting file uploads. 137 | 138 | ```ruby 139 | field :image, Upload, required: true 140 | ``` 141 | 142 | It achieves this by passing in all file upload parameters through context. This will work out of the box if you're using `GraphQL::Extras::Controller`. 143 | 144 | Here's an example using CURL: 145 | 146 | $ curl -X POST \ 147 | -F query='mutation { uploadFile(image: "image") }' \ 148 | -F image=@cats.png \ 149 | localhost:3000/graphql 150 | 151 | Take note of the correspondence between the value `"image"` and the additional HTTP parameter called `-F image=@cats.png`. 152 | 153 | See [apollo-link-upload](https://github.com/rzane/apollo-link-upload) for the client-side implementation. 154 | 155 | ### GraphQL::Extras::Test 156 | 157 | This module makes it really easy to test your schema. 158 | 159 | First, create a test schema: 160 | 161 | ```ruby 162 | # spec/support/test_schema.rb 163 | require "graphql/extras/test" 164 | 165 | class TestSchema < GraphQL::Extras::Test::Schema 166 | configure schema: Schema, queries: "spec/**/*.graphql" 167 | end 168 | ``` 169 | 170 | Now, you can run tests like so: 171 | 172 | ```ruby 173 | require "support/test_schema" 174 | 175 | RSpec.describe "hello" do 176 | let(:context) { { name: "Ray" } } 177 | let(:schema) { TestSchema.new(context) } 178 | 179 | it "allows easily executing queries" do 180 | query = schema.hello 181 | 182 | expect(query).to be_successful 183 | expect(query.data["hello"]).to eq("world") 184 | end 185 | 186 | it "parses errors" do 187 | query = schema.kaboom 188 | 189 | expect(query).not_to be_successful 190 | expect(query.errors[0].message).to eq("Invalid") 191 | expect(query.errors[0].code).to eq("VALIDATION_ERROR") 192 | end 193 | end 194 | ``` 195 | 196 | ## Development 197 | 198 | To install dependencies: 199 | 200 | $ bundle install 201 | 202 | To run the test suite: 203 | 204 | $ bundle exec rspec 205 | 206 | ## Contributing 207 | 208 | Bug reports and pull requests are welcome on GitHub at https://github.com/rzane/graphql-extras. 209 | 210 | 211 | ## License 212 | 213 | GraphQL::Extras is released under the [MIT License](MIT-LICENSE). 214 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "graphql/extras" 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 | -------------------------------------------------------------------------------- /graphql-extras.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "graphql/extras/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "graphql-extras" 7 | spec.version = GraphQL::Extras::VERSION 8 | spec.authors = ["Ray Zane"] 9 | spec.email = ["raymondzane@gmail.com"] 10 | 11 | spec.summary = %q{Utiltities for building GraphQL APIs.} 12 | spec.description = %q{A set of modules and types for buildign GraphQL APIs.} 13 | spec.homepage = "https://github.com/rzane/graphql-extras" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/rzane/graphql-extras" 17 | 18 | # Specify which files should be added to the gem when it is released. 19 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 20 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 21 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 22 | end 23 | spec.bindir = "exe" 24 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 25 | spec.require_paths = ["lib"] 26 | 27 | spec.add_dependency "activesupport", ">= 5.2" 28 | spec.add_dependency "graphql", [">= 1.12", "< 3"] 29 | 30 | spec.add_development_dependency "bundler" 31 | spec.add_development_dependency "rake" 32 | spec.add_development_dependency "rspec" 33 | spec.add_development_dependency "rspec-rails" 34 | spec.add_development_dependency "actionpack" 35 | spec.add_development_dependency "activerecord" 36 | spec.add_development_dependency "sqlite3" 37 | end 38 | -------------------------------------------------------------------------------- /lib/graphql/extras.rb: -------------------------------------------------------------------------------- 1 | require "graphql/extras/version" 2 | require "graphql/extras/types" 3 | require "graphql/extras/controller" 4 | require "graphql/extras/preload" 5 | 6 | module GraphQL 7 | module Extras 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/graphql/extras/controller.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Extras 3 | module Controller 4 | def graphql(schema:, context: {}, debug: Rails.env.development?) 5 | query = params[:query] 6 | operation = params[:operationName] 7 | variables = cast_graphql_params(params[:variables]) 8 | 9 | uploads = params.to_unsafe_h.select do |_, value| 10 | value.is_a?(ActionDispatch::Http::UploadedFile) 11 | end 12 | 13 | result = schema.execute( 14 | query, 15 | operation_name: operation, 16 | variables: variables, 17 | context: context.merge(uploads: uploads) 18 | ) 19 | 20 | render(status: 200, json: result) 21 | rescue => error 22 | raise error unless debug 23 | 24 | logger.error(error.message) 25 | logger.error(error.backtrace.join("\n")) 26 | 27 | render( 28 | status: 500, 29 | json: { 30 | data: {}, 31 | error: { 32 | message: error.message, 33 | backtrace: error.backtrace 34 | } 35 | } 36 | ) 37 | end 38 | 39 | private def cast_graphql_params(param) 40 | case param 41 | when String 42 | return {} if param.blank? 43 | cast_graphql_params(JSON.parse(param)) 44 | when Hash, ActionController::Parameters 45 | param 46 | when nil 47 | {} 48 | else 49 | raise ArgumentError, "Unexpected parameter: #{param}" 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/graphql/extras/preload.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Extras 3 | class PreloadSource < GraphQL::Dataloader::Source 4 | def initialize(preload) 5 | @preload = preload 6 | end 7 | 8 | def fetch(records) 9 | if ActiveRecord::VERSION::MAJOR >= 7 10 | preloader = ActiveRecord::Associations::Preloader.new( 11 | records: records, 12 | associations: @preload 13 | ) 14 | preloader.call 15 | else 16 | preloader = ActiveRecord::Associations::Preloader.new 17 | preloader.preload(records, @preload) 18 | end 19 | 20 | records 21 | end 22 | end 23 | 24 | module Preload 25 | # @override 26 | def initialize(*args, preload: nil, **opts, &block) 27 | @preload = preload 28 | super(*args, **opts, &block) 29 | end 30 | 31 | # @override 32 | def resolve(object, args, context) 33 | if @preload 34 | loader = context.dataloader.with(PreloadSource, @preload) 35 | loader.load(object.object) 36 | end 37 | 38 | super 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/graphql/extras/test.rb: -------------------------------------------------------------------------------- 1 | require "graphql/extras/test/schema" 2 | -------------------------------------------------------------------------------- /lib/graphql/extras/test/loader.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Extras 3 | module Test 4 | class Loader 5 | FragmentNotFoundError = Class.new(StandardError) 6 | 7 | attr_reader :fragments 8 | attr_reader :operations 9 | 10 | def initialize 11 | @fragments = {} 12 | @operations = {} 13 | end 14 | 15 | def load(path) 16 | document = ::GraphQL.parse_file(path) 17 | document.definitions.each do |node| 18 | case node 19 | when Nodes::FragmentDefinition 20 | fragments[node.name] = node 21 | when Nodes::OperationDefinition 22 | operations[node.name] = node 23 | end 24 | end 25 | end 26 | 27 | def print(operation) 28 | printer = ::GraphQL::Language::Printer.new 29 | nodes = [operation, *resolve_fragments(operation)] 30 | nodes.map { |node| printer.print(node) }.join("\n") 31 | end 32 | 33 | private 34 | 35 | Nodes = ::GraphQL::Language::Nodes 36 | 37 | # Recursively iterate through the node's fields and find 38 | # resolve all of the fragment definitions that are needed. 39 | def resolve_fragments(node) 40 | result = node.selections.flat_map do |selection| 41 | case selection 42 | when Nodes::FragmentSpread 43 | fragment = fetch_fragment!(selection.name) 44 | [fragment, *resolve_fragments(fragment)] 45 | else 46 | resolve_fragments(selection) 47 | end 48 | end 49 | 50 | result.uniq 51 | end 52 | 53 | def fetch_fragment!(name) 54 | fragments.fetch(name) do 55 | raise FragmentNotFoundError, "Fragment `#{name}` is not defined" 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/graphql/extras/test/response.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Extras 3 | module Test 4 | class Response 5 | attr_reader :data 6 | attr_reader :errors 7 | 8 | def initialize(payload) 9 | @data = payload["data"] 10 | @errors = payload.fetch("errors", []).map do |error| 11 | Error.new(error) 12 | end 13 | end 14 | 15 | def successful? 16 | errors.empty? 17 | end 18 | 19 | class Error 20 | attr_reader :message 21 | attr_reader :extensions 22 | attr_reader :code 23 | attr_reader :path 24 | attr_reader :locations 25 | 26 | def initialize(payload) 27 | @message = payload["message"] 28 | @path = payload["path"] 29 | @locations = payload["locations"] 30 | @extensions = payload["extensions"] 31 | @code = payload.dig("extensions", "code") 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/graphql/extras/test/schema.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | require "active_support/inflector" 3 | require "active_support/core_ext/hash" 4 | require "graphql/extras/test/loader" 5 | require "graphql/extras/test/response" 6 | 7 | module GraphQL 8 | module Extras 9 | module Test 10 | class Schema 11 | def self.configure(schema:, queries:) 12 | loader = Loader.new 13 | 14 | Dir.glob(queries) do |path| 15 | loader.load(path) 16 | end 17 | 18 | loader.operations.each do |name, operation| 19 | query = loader.print(operation) 20 | 21 | define_method(name.underscore) do |variables = {}| 22 | __execute(schema, query, variables) 23 | end 24 | end 25 | end 26 | 27 | def initialize(context = {}) 28 | @context = context 29 | end 30 | 31 | private 32 | 33 | def __execute(schema, query, variables) 34 | uploads = {} 35 | 36 | variables = variables.deep_transform_keys do |key| 37 | key.to_s.camelize(:lower) 38 | end 39 | 40 | variables = variables.deep_transform_values do |value| 41 | if value.respond_to? :tempfile 42 | id = SecureRandom.uuid 43 | uploads[id] = value 44 | id 45 | else 46 | value 47 | end 48 | end 49 | 50 | context = @context.merge(uploads: uploads) 51 | result = schema.execute(query, variables: variables, context: context) 52 | Response.new(result.to_h) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/graphql/extras/types.rb: -------------------------------------------------------------------------------- 1 | require "date" 2 | require "bigdecimal" 3 | require "graphql" 4 | 5 | module GraphQL 6 | module Extras 7 | module Types 8 | class DateTime < GraphQL::Types::ISO8601DateTime 9 | description <<~DESC 10 | The `DateTime` scalar type represents a date and time in the UTC 11 | timezone. The DateTime appears in a JSON response as an ISO8601 formatted 12 | string, including UTC timezone ("Z"). The parsed date and time string will 13 | be converted to UTC and any UTC offset other than 0 will be rejected. 14 | DESC 15 | end 16 | 17 | class Date < GraphQL::Schema::Scalar 18 | description <<~DESC 19 | The `Date` scalar type represents a date. The Date appears in a JSON 20 | response as an ISO8601 formatted string. 21 | DESC 22 | 23 | def self.coerce_input(value, _context) 24 | ::Date.iso8601(value) 25 | rescue ArgumentError 26 | nil 27 | end 28 | 29 | def self.coerce_result(value, _context) 30 | value.iso8601 31 | end 32 | end 33 | 34 | class Decimal < GraphQL::Schema::Scalar 35 | description <<~DESC 36 | The `Decimal` scalar type represents signed double-precision fractional 37 | values. The Decimal appears in a JSON response as a string to preserve 38 | precision. 39 | DESC 40 | 41 | def self.coerce_input(value, _context) 42 | BigDecimal(value.to_s) 43 | rescue ArgumentError 44 | nil 45 | end 46 | 47 | def self.coerce_result(value, _context) 48 | value.to_s("F") 49 | end 50 | end 51 | 52 | class Upload < GraphQL::Schema::Scalar 53 | description "Represents an uploaded file." 54 | 55 | def self.coerce_input(value, context) 56 | return nil if value.nil? 57 | 58 | uploads = context.fetch(:uploads) { 59 | raise "Expected context to include a hash of uploads." 60 | } 61 | 62 | uploads.fetch(value) do 63 | raise GraphQL::CoercionError, "No upload named `#{value}` provided." 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/graphql/extras/version.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Extras 3 | VERSION = "0.0.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/files/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rzane/graphql-extras/d8144c89c0f1e16067133a98b5c728494a4a9493/spec/fixtures/files/image.jpg -------------------------------------------------------------------------------- /spec/fixtures/graphql/explode.graphql: -------------------------------------------------------------------------------- 1 | query Explode { 2 | explode 3 | } 4 | -------------------------------------------------------------------------------- /spec/fixtures/graphql/hello.graphql: -------------------------------------------------------------------------------- 1 | query HelloWorld { 2 | hello: helloWorld 3 | } 4 | 5 | query HelloName($name: String!) { 6 | hello: helloName(name: $name) 7 | } 8 | 9 | query HelloContext { 10 | hello: helloContext 11 | } 12 | -------------------------------------------------------------------------------- /spec/fixtures/graphql/people.graphql: -------------------------------------------------------------------------------- 1 | query ListPeople { 2 | people { 3 | ...Person 4 | } 5 | allPeople: people { 6 | ...Person 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /spec/fixtures/graphql/person.graphql: -------------------------------------------------------------------------------- 1 | fragment Person on Person { 2 | name 3 | } 4 | -------------------------------------------------------------------------------- /spec/fixtures/graphql/upload_image.graphql: -------------------------------------------------------------------------------- 1 | mutation UploadImage($image: Upload!) { 2 | image: uploadImage(image: $image) 3 | } 4 | -------------------------------------------------------------------------------- /spec/graphql/extras/controller_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/rails" 2 | require "support/schema" 3 | require "graphql/extras/controller" 4 | 5 | RSpec.describe GraphQL::Extras::Controller, type: :controller do 6 | let(:json) { JSON.parse(response.body) } 7 | let(:upload) { build_upload("files/image.jpg") } 8 | 9 | controller ActionController::Base do 10 | include GraphQL::Extras::Controller 11 | 12 | def index 13 | graphql(schema: Support::Schema) 14 | end 15 | end 16 | 17 | it "executes a query against the schema" do 18 | post :index, params: { query: "{ helloWorld }" } 19 | expect(json).to eq("data" => { "helloWorld" => "Hello, world" }) 20 | end 21 | 22 | it "handles file uploads" do 23 | query = <<~GRAPHQL 24 | mutation UploadImage($image: Upload!) { 25 | uploadImage(image: $image) 26 | } 27 | GRAPHQL 28 | 29 | post :index, params: { 30 | query: query, 31 | example: upload, 32 | variables: { 33 | image: "example" 34 | } 35 | } 36 | 37 | expect(json).to eq("data" => { "uploadImage" => "image.jpg" }) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/graphql/extras/preload_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/database" 2 | 3 | RSpec.describe GraphQL::Extras::Preload do 4 | class Foo < ActiveRecord::Base 5 | end 6 | 7 | class Bar < ActiveRecord::Base 8 | belongs_to :foo 9 | end 10 | 11 | class BaseField < GraphQL::Schema::Field 12 | prepend GraphQL::Extras::Preload 13 | end 14 | 15 | class BaseObject < GraphQL::Schema::Object 16 | field_class BaseField 17 | field :id, ID, null: false 18 | end 19 | 20 | class BarType < BaseObject 21 | field :foo, BaseObject, null: false 22 | end 23 | 24 | class BatchedBarType < BaseObject 25 | field :foo, BaseObject, null: false, preload: :foo 26 | end 27 | 28 | class BatchQueryType < BaseObject 29 | field :bars, [BarType], null: false 30 | field :bars_batched, [BatchedBarType], null: false 31 | def bars; Bar.all; end 32 | def bars_batched; Bar.all; end 33 | end 34 | 35 | class BatchSchema < GraphQL::Schema 36 | query BatchQueryType 37 | use GraphQL::Dataloader 38 | end 39 | 40 | before :all do 41 | Database.setup do 42 | create_table(:foos, force: true) 43 | create_table(:bars, force: true) do |t| 44 | t.belongs_to :foo 45 | end 46 | end 47 | end 48 | 49 | before do 50 | 5.times { Bar.create!(foo: Foo.create!) } 51 | end 52 | 53 | it "fires N+1 queries without batching" do 54 | query = <<~GRAPHQL 55 | query { 56 | bars { 57 | id 58 | foo { id } 59 | } 60 | } 61 | GRAPHQL 62 | 63 | count = Database.count_queries(/SELECT.*foos/i) do 64 | BatchSchema.execute(query) 65 | end 66 | 67 | expect(count).to eq(5) 68 | end 69 | 70 | it "fires 1 query with batching" do 71 | query = <<~GRAPHQL 72 | query { 73 | barsBatched { 74 | id 75 | foo { id } 76 | } 77 | } 78 | GRAPHQL 79 | 80 | count = Database.count_queries(/SELECT.*foos/i) do 81 | BatchSchema.execute(query) 82 | end 83 | 84 | expect(count).to eq(1) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/graphql/extras/test/loader_spec.rb: -------------------------------------------------------------------------------- 1 | require "graphql/extras/test/loader" 2 | 3 | RSpec.describe GraphQL::Extras::Test::Loader do 4 | Loader = GraphQL::Extras::Test::Loader 5 | 6 | it "initializes" do 7 | loader = Loader.new 8 | expect(loader.fragments).to eq({}) 9 | expect(loader.operations).to eq({}) 10 | end 11 | 12 | it "loads a query" do 13 | loader = Loader.new 14 | loader.load "spec/fixtures/graphql/people.graphql" 15 | expect(loader.operations).to have_key("ListPeople") 16 | end 17 | 18 | it "loads a fragment" do 19 | loader = Loader.new 20 | loader.load "spec/fixtures/graphql/person.graphql" 21 | expect(loader.fragments).to have_key("Person") 22 | end 23 | 24 | it "raises when a fragment is not found" do 25 | loader = Loader.new 26 | loader.load "spec/fixtures/graphql/people.graphql" 27 | 28 | operation = loader.operations["ListPeople"] 29 | expect { loader.print(operation) }.to raise_error(Loader::FragmentNotFoundError) 30 | end 31 | 32 | it "prints an operation, including fragments, with no duplicates" do 33 | loader = Loader.new 34 | loader.load "spec/fixtures/graphql/person.graphql" 35 | loader.load "spec/fixtures/graphql/people.graphql" 36 | 37 | operation = loader.operations["ListPeople"] 38 | graphql = loader.print(operation) 39 | 40 | expect(graphql).to include("query ListPeople") 41 | expect(graphql.scan("fragment Person").count).to eq 1 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/graphql/extras/test/schema_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/schema" 2 | require "graphql/extras/test/schema" 3 | 4 | class TestSchema < GraphQL::Extras::Test::Schema 5 | configure( 6 | schema: Support::Schema, 7 | queries: "spec/fixtures/graphql/*.graphql" 8 | ) 9 | end 10 | 11 | RSpec.describe GraphQL::Extras::Test::Schema do 12 | let(:schema) { TestSchema.new } 13 | 14 | it "executes a query" do 15 | query = schema.hello_world 16 | 17 | expect(query).to be_successful 18 | expect(query.data["hello"]).to eq("Hello, world") 19 | end 20 | 21 | it "executes a query with variables" do 22 | query = schema.hello_name(name: "argument") 23 | 24 | expect(query).to be_successful 25 | expect(query.data["hello"]).to eq("Hello, argument") 26 | end 27 | 28 | it "executes a query with context" do 29 | schema = TestSchema.new(name: "context") 30 | query = schema.hello_context 31 | 32 | expect(query).to be_successful 33 | expect(query.data["hello"]).to eq("Hello, context") 34 | end 35 | 36 | it "executes a query with uploads" do 37 | upload = build_upload("files/image.jpg") 38 | query = schema.upload_image(image: upload) 39 | 40 | expect(query).to be_successful 41 | expect(query.data["image"]).to eq("image.jpg") 42 | end 43 | 44 | it "executes a query with errors" do 45 | query = schema.explode 46 | 47 | expect(query).not_to be_successful 48 | expect(query.errors[0].message).to eq("Boom!") 49 | expect(query.errors[0].code).to eq("BOOM") 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/graphql/extras/types_spec.rb: -------------------------------------------------------------------------------- 1 | require "graphql/extras/types" 2 | require "action_dispatch/http/upload" 3 | 4 | RSpec.describe GraphQL::Extras::Types do 5 | describe GraphQL::Extras::Types::Decimal do 6 | it "parses a decimal" do 7 | value = described_class.coerce_input("5.5", {}) 8 | expect(value).to eq(BigDecimal("5.5")) 9 | end 10 | 11 | it "translates an invalid value to nil" do 12 | value = described_class.coerce_input("", {}) 13 | expect(value).to eq(nil) 14 | end 15 | 16 | it "converts a decimal to a string" do 17 | value = described_class.coerce_result(BigDecimal("5.5"), {}) 18 | expect(value).to eq("5.5") 19 | end 20 | end 21 | 22 | describe GraphQL::Extras::Types::Date do 23 | it "parses a valid date" do 24 | value = described_class.coerce_input("2019-01-01", {}) 25 | expect(value).to eq(Date.new(2019, 1, 1)) 26 | end 27 | 28 | it "translates an invalid value to nil" do 29 | value = described_class.coerce_input("1/1/2019", {}) 30 | expect(value).to eq(nil) 31 | end 32 | 33 | it "converts a date to a string" do 34 | value = described_class.coerce_result(Date.new(2019, 1, 1), {}) 35 | expect(value).to eq("2019-01-01") 36 | end 37 | end 38 | 39 | describe GraphQL::Extras::Types::Upload do 40 | let(:upload) { 41 | ActionDispatch::Http::UploadedFile.new( 42 | filename: "image.jpg", 43 | tempfile: "/tmp/image.jpg" 44 | ) 45 | } 46 | 47 | it "extracts an upload from context" do 48 | context = { uploads: { "foo" => upload } } 49 | value = described_class.coerce_input("foo", context) 50 | expect(value).to eq(upload) 51 | end 52 | 53 | it "raises an error when uploads are not passed into context" do 54 | expect { 55 | described_class.coerce_input("foo", {}) 56 | }.to raise_error(RuntimeError, /hash of uploads/) 57 | end 58 | 59 | it "raises an error when upload does not exist in context" do 60 | context = { uploads: {} } 61 | 62 | expect { 63 | described_class.coerce_input("foo", context) 64 | }.to raise_error(GraphQL::CoercionError, "No upload named `foo` provided.") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/graphql/extras_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GraphQL::Extras do 2 | it "has a version number" do 3 | expect(GraphQL::Extras::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "rack/test" 3 | require "graphql/extras" 4 | 5 | module UploadHelpers 6 | def build_upload(fixture) 7 | path = File.join(__dir__, "fixtures", fixture) 8 | Rack::Test::UploadedFile.new(path) 9 | end 10 | end 11 | 12 | RSpec.configure do |config| 13 | # Enable flags like --only-failures and --next-failure 14 | config.example_status_persistence_file_path = ".rspec_status" 15 | 16 | # Disable RSpec exposing methods globally on `Module` and `main` 17 | config.disable_monkey_patching! 18 | 19 | config.expect_with :rspec do |c| 20 | c.syntax = :expect 21 | end 22 | 23 | config.include UploadHelpers 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | require "sqlite3" 2 | require "active_support/all" 3 | require "active_record" 4 | 5 | module Database 6 | def self.setup(&block) 7 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 8 | ActiveRecord::Migration.verbose = false 9 | ActiveRecord::Schema.define(&block) 10 | end 11 | 12 | def self.count_queries(matching) 13 | count = 0 14 | ActiveSupport::Notifications.subscribe('sql.active_record') do |_, _, _, _, values| 15 | count += 1 if values[:sql] && values[:sql] =~ matching 16 | end 17 | yield 18 | count 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/rails.rb: -------------------------------------------------------------------------------- 1 | require "active_support/all" 2 | require "action_view" 3 | require "action_controller" 4 | require "action_dispatch" 5 | require "rspec/rails" 6 | 7 | module Rails 8 | # Stub out Rails in order to act like we're running a live Rails app. 9 | 10 | class Application 11 | def env_config 12 | {} 13 | end 14 | 15 | def routes 16 | ActionDispatch::Routing::RouteSet.new 17 | end 18 | end 19 | 20 | def self.application 21 | Application.new 22 | end 23 | 24 | def self.env 25 | ActiveSupport::StringInquirer.new('test') 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | module Support 2 | class Person < GraphQL::Schema::Object 3 | field :name, String, null: false 4 | end 5 | 6 | class Query < GraphQL::Schema::Object 7 | field :hello_world, String, null: false 8 | field :hello_context, String, null: false 9 | field :hello_name, String, null: false do 10 | argument :name, String, required: true 11 | end 12 | 13 | field :explode, String, null: false 14 | field :people, [Person], null: false 15 | 16 | def hello_world 17 | "Hello, world" 18 | end 19 | 20 | def hello_name(name:) 21 | "Hello, #{name}" 22 | end 23 | 24 | def hello_context 25 | "Hello, #{context[:name]}" 26 | end 27 | 28 | def explode 29 | raise GraphQL::ExecutionError.new("Boom!", extensions: {code: "BOOM"}) 30 | end 31 | 32 | def people 33 | [{name: "Rick"}] 34 | end 35 | end 36 | 37 | class Mutation < GraphQL::Schema::Object 38 | include GraphQL::Extras::Types 39 | 40 | field :upload_image, String, null: false do 41 | argument :image, Upload, required: true 42 | end 43 | 44 | def upload_image(image:) 45 | image.original_filename 46 | end 47 | end 48 | 49 | class Schema < GraphQL::Schema 50 | query(Query) 51 | mutation(Mutation) 52 | end 53 | end 54 | --------------------------------------------------------------------------------