├── .github ├── dependabot.yaml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── graphql-client.gemspec ├── guides ├── collocated-call-sites.md ├── controllers.md ├── dynamic-query-error.md ├── handling-errors.md ├── helpers.md ├── heredoc.md ├── implicitly-fetched-field-error.md ├── local-queries.md ├── over-under-fetching.md ├── rails-configuration.md ├── remote-queries.md ├── templates.md ├── unfetched-field-error.md └── unimplemented-field-error.md ├── lib ├── graphql │ ├── client.rb │ └── client │ │ ├── collocated_enforcement.rb │ │ ├── definition.rb │ │ ├── definition_variables.rb │ │ ├── document_types.rb │ │ ├── erb.rb │ │ ├── error.rb │ │ ├── errors.rb │ │ ├── erubi_enhancer.rb │ │ ├── erubis.rb │ │ ├── erubis_enhancer.rb │ │ ├── fragment_definition.rb │ │ ├── hash_with_indifferent_access.rb │ │ ├── http.rb │ │ ├── list.rb │ │ ├── log_subscriber.rb │ │ ├── operation_definition.rb │ │ ├── query_typename.rb │ │ ├── railtie.rb │ │ ├── response.rb │ │ ├── schema.rb │ │ ├── schema │ │ ├── base_type.rb │ │ ├── enum_type.rb │ │ ├── include_directive.rb │ │ ├── interface_type.rb │ │ ├── list_type.rb │ │ ├── non_null_type.rb │ │ ├── object_type.rb │ │ ├── possible_types.rb │ │ ├── scalar_type.rb │ │ ├── skip_directive.rb │ │ └── union_type.rb │ │ ├── type_stack.rb │ │ └── view_module.rb └── rubocop │ └── cop │ └── graphql │ ├── heredoc.rb │ └── overfetch.rb └── test ├── foo_helper.rb ├── test_client.rb ├── test_client_create_operation.rb ├── test_client_errors.rb ├── test_client_fetch.rb ├── test_client_schema.rb ├── test_client_validation.rb ├── test_collocated_enforcement.rb ├── test_definition_variables.rb ├── test_erb.rb ├── test_hash_with_indifferent_access.rb ├── test_http.rb ├── test_object_typename.rb ├── test_operation_slice.rb ├── test_query_result.rb ├── test_query_typename.rb ├── test_rubocop_heredoc.rb ├── test_rubocop_overfetch.rb ├── test_schema.rb ├── test_view_module.rb └── views └── users ├── _profile.html.erb ├── overfetch.html.erb ├── profile └── _show.html.erb ├── show-2-3.html.erb └── show.html.erb /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore(deps)" 9 | groups: 10 | dependencies: 11 | applies-to: version-updates 12 | update-types: 13 | - "minor" 14 | - "patch" 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | commit-message: 20 | prefix: "chore(deps)" 21 | groups: 22 | dependencies: 23 | applies-to: version-updates 24 | update-types: 25 | - "minor" 26 | - "patch" 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | jobs: 6 | build: 7 | name: Test on Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}, graphql-ruby ${{ matrix.graphql_version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | ruby_version: 13 | - '2.7' 14 | - '3.0' 15 | - '3.1' 16 | - '3.2' 17 | graphql_version: 18 | - "~> 1.13.0" 19 | - "~> 2.0.0" 20 | - "~> 2.1.0" 21 | - "~> 2.2.0" 22 | rails_version: 23 | - "~> 5.2.0" 24 | - "~> 6.0.0" 25 | - "~> 6.1.0" 26 | - "~> 7.0.0" 27 | - "~> 7.1.0" 28 | steps: 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 30 | - name: Set up Ruby ${{ matrix.ruby_version }} 31 | uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb # v1 32 | with: 33 | ruby-version: ${{ matrix.ruby_version }} 34 | - name: Build and test 35 | run: | 36 | bundle install --jobs 4 --retry 3 37 | bundle exec rake test 38 | env: 39 | RAILS_VERSION: ${{ matrix.rails_version }} 40 | GRAPHQL_VERSION: ${{ matrix.graphql_version }} 41 | rubocop: 42 | name: Rubocop 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 46 | - name: Set up Ruby 47 | uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb # v1 48 | with: 49 | ruby-version: 3.2 50 | - name: Build and test 51 | run: |- 52 | bundle install --jobs 4 --retry 3 53 | bundle exec rake rubocop 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | permissions: 7 | contents: write 8 | id-token: write 9 | jobs: 10 | release: 11 | name: Release to RubyGems 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | - uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb # v1 16 | with: 17 | bundler-cache: true 18 | ruby-version: ruby 19 | - uses: rubygems/release-gem@a25424ba2ba8b387abc8ef40807c2c85b96cbe32 # v1 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | pkg 3 | .ruby-version -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-github: config/default.yml 3 | 4 | require: 5 | - ./lib/rubocop/cop/graphql/heredoc 6 | 7 | AllCops: 8 | TargetRubyVersion: 2.7 9 | 10 | GraphQL/Heredoc: 11 | Enabled: true 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | gemspec 4 | 5 | rails_version = ENV["RAILS_VERSION"] == "edge" ? { github: "rails/rails" } : ENV["RAILS_VERSION"] 6 | gem "actionpack", rails_version 7 | gem "activesupport", rails_version 8 | gem "concurrent-ruby", "1.3.4" 9 | 10 | graphql_version = ENV["GRAPHQL_VERSION"] == "edge" ? { github: "rmosolgo/graphql-ruby", ref: "interpreter-without-legacy" } : ENV["GRAPHQL_VERSION"] 11 | gem "graphql", graphql_version 12 | 13 | group :development, :test do 14 | gem "debug", ">= 1.0.0" 15 | end 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-client [![Gem Version](https://badge.fury.io/rb/graphql-client.svg)](https://badge.fury.io/rb/graphql-client) [![CI](https://github.com/github-community-projects/graphql-client/workflows/CI/badge.svg)](https://github.com/github-community-projects/graphql-client/actions?query=workflow) 2 | 3 | GraphQL Client is a Ruby library for declaring, composing and executing GraphQL queries. 4 | 5 | ## Usage 6 | 7 | ### Installation 8 | 9 | Add `graphql-client` to your Gemfile and then run `bundle install`. 10 | 11 | ```ruby 12 | # Gemfile 13 | gem 'graphql-client' 14 | ``` 15 | 16 | ### Configuration 17 | 18 | Sample configuration for a GraphQL Client to query from the [SWAPI GraphQL Wrapper](https://github.com/graphql/swapi-graphql). 19 | 20 | ```ruby 21 | require "graphql/client" 22 | require "graphql/client/http" 23 | 24 | # Star Wars API example wrapper 25 | module SWAPI 26 | # Configure GraphQL endpoint using the basic HTTP network adapter. 27 | HTTP = GraphQL::Client::HTTP.new("https://example.com/graphql") do 28 | def headers(context) 29 | # Optionally set any HTTP headers 30 | { "User-Agent": "My Client" } 31 | end 32 | end 33 | 34 | # Fetch latest schema on init, this will make a network request 35 | Schema = GraphQL::Client.load_schema(HTTP) 36 | 37 | # However, it's smart to dump this to a JSON file and load from disk 38 | # 39 | # Run it from a script or rake task 40 | # GraphQL::Client.dump_schema(SWAPI::HTTP, "path/to/schema.json") 41 | # 42 | # Schema = GraphQL::Client.load_schema("path/to/schema.json") 43 | 44 | Client = GraphQL::Client.new(schema: Schema, execute: HTTP) 45 | end 46 | ``` 47 | 48 | ### Defining Queries 49 | 50 | If you haven't already, [familiarize yourself with the GraphQL query syntax](http://graphql.org/docs/queries/). Queries are declared with the same syntax inside of a `<<-'GRAPHQL'` heredoc. There isn't any special query builder Ruby DSL. 51 | 52 | This client library encourages all GraphQL queries to be declared statically and assigned to a Ruby constant. 53 | 54 | ```ruby 55 | HeroNameQuery = SWAPI::Client.parse <<-'GRAPHQL' 56 | query { 57 | hero { 58 | name 59 | } 60 | } 61 | GRAPHQL 62 | ``` 63 | 64 | Queries can reference variables that are passed in at query execution time. 65 | 66 | ```ruby 67 | HeroFromEpisodeQuery = SWAPI::Client.parse <<-'GRAPHQL' 68 | query($episode: Episode) { 69 | hero(episode: $episode) { 70 | name 71 | } 72 | } 73 | GRAPHQL 74 | ``` 75 | 76 | Fragments are declared similarly. 77 | 78 | ```ruby 79 | HumanFragment = SWAPI::Client.parse <<-'GRAPHQL' 80 | fragment on Human { 81 | name 82 | homePlanet 83 | } 84 | GRAPHQL 85 | ``` 86 | 87 | To include a fragment in a query, reference the fragment by constant. 88 | 89 | ```ruby 90 | HeroNameQuery = SWAPI::Client.parse <<-'GRAPHQL' 91 | { 92 | luke: human(id: "1000") { 93 | ...HumanFragment 94 | } 95 | leia: human(id: "1003") { 96 | ...HumanFragment 97 | } 98 | } 99 | GRAPHQL 100 | ``` 101 | 102 | This works for namespaced constants. 103 | 104 | ```ruby 105 | module Hero 106 | Query = SWAPI::Client.parse <<-'GRAPHQL' 107 | { 108 | luke: human(id: "1000") { 109 | ...Human::Fragment 110 | } 111 | leia: human(id: "1003") { 112 | ...Human::Fragment 113 | } 114 | } 115 | GRAPHQL 116 | end 117 | ``` 118 | 119 | `::` is invalid in regular GraphQL syntax, but `#parse` makes an initial pass on the query string and resolves all the fragment spreads with [`constantize`](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-constantize). 120 | 121 | ### Executing queries 122 | 123 | Pass the reference of a parsed query definition to `GraphQL::Client#query`. Data is returned back in a wrapped `GraphQL::Client::Schema::ObjectType` struct that provides Ruby-ish accessors. 124 | 125 | ```ruby 126 | result = SWAPI::Client.query(Hero::Query) 127 | 128 | # The raw data is Hash of JSON values 129 | # result["data"]["luke"]["homePlanet"] 130 | 131 | # The wrapped result allows to you access data with Ruby methods 132 | result.data.luke.home_planet 133 | ``` 134 | 135 | `GraphQL::Client#query` also accepts variables and context parameters that can be leveraged by the underlying network executor. 136 | 137 | ```ruby 138 | result = SWAPI::Client.query(Hero::HeroFromEpisodeQuery, variables: {episode: "JEDI"}, context: {user_id: current_user_id}) 139 | ``` 140 | 141 | ### Rails ERB integration 142 | 143 | If you're using Ruby on Rails ERB templates, theres a ERB extension that allows static queries to be defined in the template itself. 144 | 145 | In standard Ruby you can simply assign queries and fragments to constants and they'll be available throughout the app. However, the contents of an ERB template is compiled into a Ruby method, and methods can't assign constants. So a new ERB tag was extended to declare static sections that include a GraphQL query. 146 | 147 | ```erb 148 | <%# app/views/humans/human.html.erb %> 149 | <%graphql 150 | fragment HumanFragment on Human { 151 | name 152 | homePlanet 153 | } 154 | %> 155 | 156 |

<%= human.name %> lives on <%= human.home_planet %>.

157 | ``` 158 | 159 | These `<%graphql` sections are simply ignored at runtime but make their definitions available through constants. The module namespacing is derived from the `.erb`'s path plus the definition name. 160 | 161 | ``` 162 | >> "views/humans/human".camelize 163 | => "Views::Humans::Human" 164 | >> Views::Humans::Human::HumanFragment 165 | => # 166 | ``` 167 | 168 | ## Examples 169 | 170 | [github/github-graphql-rails-example](https://github.com/github/github-graphql-rails-example) is an example application using this library to implement views on the GitHub GraphQL API. 171 | 172 | ## Installation 173 | 174 | Add `graphql-client` to your app's Gemfile: 175 | 176 | ```ruby 177 | gem 'graphql-client' 178 | ``` 179 | 180 | ## See Also 181 | 182 | * [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) gem which implements 80% of what this library provides. ❤️ [@rmosolgo](https://github.com/rmosolgo) 183 | * [Facebook's GraphQL homepage](http://graphql.org/) 184 | * [Facebook's Relay homepage](https://facebook.github.io/relay/) 185 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rake/testtask" 3 | require "rubocop/rake_task" 4 | require "bundler/gem_tasks" 5 | 6 | task default: [:test, :rubocop] 7 | 8 | Rake::TestTask.new do |t| 9 | t.warning = false 10 | end 11 | 12 | RuboCop::RakeTask.new 13 | -------------------------------------------------------------------------------- /graphql-client.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Gem::Specification.new do |s| 3 | s.name = "graphql-client" 4 | s.version = "0.26.0" 5 | s.summary = "GraphQL Client" 6 | s.description = "A Ruby library for declaring, composing and executing GraphQL queries" 7 | s.homepage = "https://github.com/github-community-projects/graphql-client" 8 | s.license = "MIT" 9 | s.metadata = { "rubygems_mfa_required" => "true" } 10 | s.files = Dir["README.md", "LICENSE", "lib/**/*.rb"] 11 | 12 | s.add_dependency "activesupport", ">= 3.0" 13 | s.add_dependency "graphql", ">= 1.13.0" 14 | 15 | s.add_development_dependency "actionpack", ">= 3.2.22" 16 | s.add_development_dependency "erubi", "~> 1.6" 17 | s.add_development_dependency "erubis", "~> 2.7" 18 | s.add_development_dependency "minitest", "~> 5.9" 19 | s.add_development_dependency "rake", "~> 13.3.0" 20 | s.add_development_dependency "rubocop-github" 21 | s.add_development_dependency "rubocop", "~> 1.75.8" 22 | 23 | s.required_ruby_version = ">= 2.1.0" 24 | 25 | s.email = "engineering@github.com" 26 | s.authors = "GitHub" 27 | end 28 | -------------------------------------------------------------------------------- /guides/collocated-call-sites.md: -------------------------------------------------------------------------------- 1 | # Collocated Call Sites 2 | 3 | The collocation best practice comes from the [Relay.js](https://facebook.github.io/relay/) library where GraphQL queries and views always live side by side to make it possible to reason about isolated components of an application. Both the query and display form one highly cohesive unit. Callers are decoupled from the data dependencies the function requires. 4 | 5 | ## Ruby method collocation 6 | 7 | ```ruby 8 | PageTitleFragment = SWAPI::Client.parse <<-'GRAPHQL' 9 | fragment on Human { 10 | name 11 | homePlanet 12 | } 13 | GRAPHQL 14 | 15 | def page_title(human) 16 | human = PageTitleFragment.new(human) 17 | 18 | tag(:title, "#{human.name} from #{human.home_planet}") 19 | end 20 | ``` 21 | 22 | Both the fragment definition and helper logic are side by side as a single cohesive unit. This is a one to one relationship. A fragment definition should only be used by one helper method. 23 | 24 | You can clearly see that both `name` and `homePlanet` are used by this helper method and no extra fields have been queried or used at runtime. 25 | 26 | Additional fields maybe queried without any change to this functions call sites. 27 | 28 | ```diff 29 | PageTitleFragment = SWAPI::Client.parse <<-'GRAPHQL' 30 | fragment on Human { 31 | name 32 | - homePlanet 33 | + age 34 | } 35 | GRAPHQL 36 | 37 | def page_title(human) 38 | human = PageTitleFragment.new(human) 39 | 40 | tag(:title, "#{human.name} is #{human.age} years old") 41 | end 42 | ``` 43 | 44 | ## ERB Collocation 45 | 46 | ```erb 47 | <%graphql 48 | fragment Human on Human { 49 | name 50 | homePlanet 51 | } 52 | %> 53 | <% human = Views::Humans::Show::Human.new(human) %> 54 | 55 | <%= human.name %> from <%= human.home_planet %> 56 | ``` 57 | 58 | Since ERB templates can not define static constants, a special `<%graphql` section tag provides a way to declare a fragment for the template. 59 | 60 | As with the plain old ruby method, you can still clearly see that both `name` and `homePlanet` are used by this template and no extra fields have been queried or used at runtime. 61 | 62 | ## Pitfalls 63 | 64 | ### Sharing definitions between multiple helpers 65 | 66 | ```ruby 67 | # bad 68 | SharedFragment = SWAPI::Client.parse <<-'GRAPHQL' 69 | fragment on Human { 70 | name 71 | homePlanet 72 | } 73 | GRAPHQL 74 | 75 | def human_header(human) 76 | human = SharedFragment.new(human) 77 | 78 | content_tag(:h1, human.name.capitalize) 79 | end 80 | 81 | def page_title(human) 82 | human = SharedFragment.new(human) 83 | 84 | content_tag(:title, "#{human.name} from #{human.home_planet}") 85 | end 86 | ``` 87 | 88 | While the `page_title` uses both `name` and `homePlanet` fields, `human_header` only uses `name`. This means any caller of `human_header` must unnecessarily fetch the data for `homePlanet`. This is an example of "over-fetching". 89 | 90 | Avoid this by defining separate fragments for `human_header` and `page_title`. 91 | 92 | ### Sharing object references with logic outside the current module 93 | 94 | ```erb 95 | <%graphql 96 | fragment Human on Human { 97 | name 98 | homePlanet 99 | } 100 | %> 101 | <% human = Views::Humans::Show::Human.new(human) %> 102 | 103 | <%= page_title(human) %> 104 | ``` 105 | 106 | Just looking at this template it appears that none of the fields queried are actually used. But until we dig into the helper methods do we see they are implicitly accessed by other logic. This breaks our ability to locally reason about the template data requirements. 107 | 108 | ```ruby 109 | # bad 110 | def page_title(human) 111 | page_title_via_more_indirection(human) 112 | end 113 | 114 | # bad 115 | def page_title_via_more_indirection(human) 116 | tag(:title, "#{human.name} from #{human.home_planet}") 117 | end 118 | ``` 119 | 120 | Instead, declare and explicitly include the dependencies for helper methods that may receive GraphQL data objects. This decouples the `page_title` from changes to the ERB `Human` fragment. 121 | 122 | ```erb 123 | <%graphql 124 | fragment Human on Human { 125 | ...HumanHelper::PageTitleFragment 126 | } 127 | %> 128 | <% human = Views::Humans::Show::Human.new(human) %> 129 | 130 | <%= page_title(human) %> 131 | ``` 132 | 133 | ```ruby 134 | PageTitleFragment = SWAPI::Client.parse <<-'GRAPHQL' 135 | fragment on Human { 136 | name 137 | homePlanet 138 | } 139 | GRAPHQL 140 | 141 | def page_title(human) 142 | tag(:title, "#{human.name} from #{human.home_planet}") 143 | end 144 | ``` 145 | 146 | ## See Also 147 | 148 | * [Over-fetching and under-fetching](over-under-fetching.md) 149 | -------------------------------------------------------------------------------- /guides/controllers.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | ### Traditional Rails Controller responsibilities 4 | 5 | * Enforce authentication, `before_filter :login_required` 6 | * Load records from URL parameters, `Issue.find(params[:id])` 7 | * Enforce resource authorization 8 | * View specific association eager load optimizations, `includes(:comments, :labels)` 9 | * Render template, JSON or redirect 10 | * Update record from form parameters 11 | 12 | ### Platform Controllers 13 | 14 | Controllers written with GraphQL queries will delegate all content authorization and record loading concerns to the GraphQL server. Actions will primarily be responsible for constructing a GraphQL query from the URL `params`, executing the query and passing the result data to a template or partial view. 15 | 16 | ```ruby 17 | class IssuesController < ApplicationController 18 | # Statically define any GraphQL queries as constants. This avoids query string 19 | # parsing at runtime and ensures we can statically validate all queries for 20 | # errors. 21 | # 22 | # This defines how params data maps to a GraphQL query to find an Issue node. 23 | ShowQuery = FooApp::Client.parse <<-'GRAPHQL' 24 | query($user: String!, $repository: String!, number: Int!) { 25 | user(login: $user) { 26 | repository(name: $repository) { 27 | issue(number: $number) { 28 | ...Views::Issues::Show::Issue 29 | } 30 | } 31 | } 32 | } 33 | GRAPHQL 34 | 35 | def show 36 | # Execute our static query against URL params. All queries are executed in 37 | # the context of a "current_user". 38 | data = query ShowQuery, params.slice(:user, :repository, :number) 39 | 40 | # Check if the Issue node was found, if not the issue might not exist or 41 | # we just don't have permission to see it. 42 | if issue = data.user.repository.issue 43 | # Render the "issue/show" template with our data hash. 44 | render "issues/show", issue: issue 45 | else 46 | head :not_found 47 | end 48 | end 49 | end 50 | ``` 51 | 52 | ### Data is already scoped by viewer 53 | 54 | The GraphQL API will not let the current user see or modify data they do not have access to. This obsoletes the need to do `before_filter :login_required` and scoped lookups. This authorization logic is implemented once by the API and not duplicated and scattered across multiple controllers. 55 | 56 | The controller only needs to handle object existence and 404 when no data is returned. 57 | 58 | ### Data is tailored to specific view hierarchies 59 | 60 | With ActiveRecord we could expose objects like `@repository` so any view could lazily traverse its attributes and associations. This object could be generically set by a `before_filter` and used freely by any subview. But this leads to unpredictable data access. Any one of these views could load traverse expense associations off this object. 61 | 62 | Instead, views will explicitly declare their data dependencies. They'll only get the data they ask for. Its only useful to the view that requested it and therefore should be passed explicitly as a `:locals`. Also, actions within the same controller will be asking for different properties of the "repository" so having a shared `find_repository` before filter step no longer applies. 63 | 64 | ## See also 65 | 66 | [github-graphql-rails-example](https://github.com/github/github-graphql-rails-example) template examples: 67 | 68 | * [app/controller/repositories_controller.rb](https://github.com/github/github-graphql-rails-example/blob/master/app/controllers/repositories_controller.rb) defines the top level GraphQL queries to fetch repository list and show pages. 69 | -------------------------------------------------------------------------------- /guides/dynamic-query-error.md: -------------------------------------------------------------------------------- 1 | # GraphQL::Client::DynamicQueryError 2 | 3 | Raised when trying to execute a query that was not assigned to at static constant. 4 | 5 | ```ruby 6 | # good 7 | HeroNameQuery = SWAPI::Client.parse <<-'GRAPHQL' 8 | query($id: ID!) { 9 | hero(id: $id) { 10 | name 11 | } 12 | } 13 | GRAPHQL 14 | result = SWAPI::Client.query(HeroNameQuery, variables: { id: params[:id] }) 15 | ``` 16 | 17 | ```ruby 18 | # bad 19 | hero_query = SWAPI::Client.parse <<-'GRAPHQL' 20 | query($id: ID!) { 21 | hero(id: $id) { 22 | name 23 | } 24 | } 25 | GRAPHQL 26 | result = SWAPI::Client.query(HeroNameQuery, variables: { id: params[:id] }) 27 | ``` 28 | 29 | Parsing a query and validating a query on every request adds performance overhead. It also prevents validation errors from being discovered until request time, rather than when the query is parsed at startup. 30 | 31 | ```ruby 32 | # horrible 33 | hero_query = SWAPI::Client.parse <<-GRAPHQL 34 | query { 35 | hero(id: "#{id}") { 36 | name 37 | } 38 | } 39 | GRAPHQL 40 | result = SWAPI::Client.query(hero_query) 41 | ``` 42 | 43 | Building runtime GraphQL query strings with user input may lead to security issues. Always using a static query along with variables is a best practice. 44 | -------------------------------------------------------------------------------- /guides/handling-errors.md: -------------------------------------------------------------------------------- 1 | # Handling Errors 2 | 3 | There are two general types of GraphQL operation errors. 4 | 5 | 1. Parse or Validation errors 6 | 2. Execution errors 7 | 8 | ## Parse/Validation errors 9 | 10 | Making a query to a server with invalid query syntax or against fields that don't exist will fail the entire operation. No data is returned. 11 | 12 | ```ruby 13 | response = Client.query(BadQuery) 14 | response.data #=> nil 15 | response.errors[:data] #=> "Field 'missing' doesn't exist on type 'Query'" 16 | ``` 17 | 18 | However, you're less likely to encounter these types of as since queries are validated locally on the client side before they are even sent. Ensure the `Client` instance is configured with the correct `GraphQL::Schema` and is up-to-date. 19 | 20 | ## Execution errors 21 | 22 | Execution errors occur while the server if resolving the query operation. These errors may be the clients fault (like a HTTP 4xx), others could be a server issue (HTTP 5xx). 23 | 24 | The errors API was modeled after [`ActiveModel::Errors`](http://api.rubyonrails.org/classes/ActiveModel/Errors.html). So it should be familiar if you're working with Rails. 25 | 26 | ```ruby 27 | class IssuesController < ApplicationController 28 | ShowQuery = FooApp::Client.parse <<-'GRAPHQL' 29 | query($id: ID!) { 30 | issue: node(id: $id) { 31 | ...Views::Issues::Show::Issue 32 | } 33 | } 34 | GRAPHQL 35 | 36 | def show 37 | # Always returns a GraphQL::Client::Response 38 | response = FooApp::Client.query(ShowQuery, variables: { id: params[:id] }) 39 | 40 | # Response#data is nullable. In the case of nil, a well behaved server 41 | # will populate Response#errors with an explanation. 42 | if data = response.data 43 | 44 | # A Relay node() lookup is nullable so we should conditional check if 45 | # the id was found. 46 | if issue = data.issue 47 | render "issues/show", issue: issue 48 | 49 | # Otherwise, the server will likely give us a message about why the node() 50 | # lookup failed. 51 | elsif data.errors[:issue].any? 52 | # "Could not resolve to a node with the global id of 'abc'" 53 | message = data.errors[:issue].join(", ") 54 | render status: :not_found, plain: message 55 | end 56 | 57 | # Parse/validation errors will have `response.data = nil`. The top level 58 | # errors object will report these. 59 | elsif response.errors.any? 60 | # "Could not resolve to a node with the global id of 'abc'" 61 | message = response.errors[:issue].join(", ") 62 | render status: :internal_server_error, plain: message 63 | end 64 | end 65 | end 66 | ``` 67 | 68 | ## Partial data sets 69 | 70 | While validation errors never return any data to the client, execution errors have the ability to return partial data sets. The majority of a operation may be fulfilled, but slow calculation may have timed out or an internal service only a few fields could be down for maintenance. 71 | 72 | Its important to remember that partial data being returned will still validate against the schema's type system. If a field is marked as non-nullable, it won't all the sudden come back `null` on a timeout. In this way, error handling becomes part of your existing nullable conditional checks. Forgetting to handle a error will graceful data to a "no data" case rather than causing an error. 73 | 74 | ### Nullable fields 75 | 76 | An issue may or may not have an assignee. So we already need a guard to check if the value is present. In this case, we can also choose to look for errors loading the assignee. 77 | 78 | ```erb 79 | <% if issue.assignee %> 80 | <%= render "assignee", user: issue.assignee %> 81 | <% elsif issue.errors[:assignee] %> 82 |

Something went wrong loading the assignee.

83 | <% end %> 84 | ``` 85 | 86 | ### Default values 87 | 88 | Scalar values that are non-nullable may return a sensible default value when there is an error fetching the data. Then set an error to inform the client that the data maybe wrong and they can choose to display it with a warning or not all all. If the client neglects to handle the error, the view can still be rendered with a default value. 89 | 90 | ```erb 91 | <% if repository.errors[:watchers_count].any? %> 92 | 93 | <% end %> 94 | 95 | <%= repository.watchers_count %> Watchers 96 | ``` 97 | 98 | ### Empty or truncated collections 99 | 100 | If an execution error occurs loading a collection of data, an empty list may be returned to the client. 101 | 102 | ```erb 103 | <% if repository.errors[:search_results].any? %> 104 |

Search is down

105 | <% else %> 106 | <% repository.search_results.nodes.each do |result| %> 107 | <%= result.title %> 108 | <% end %> 109 | <% end %> 110 | ``` 111 | 112 | The list could also be partial populated and truncated because of a timeout. 113 | 114 | ```erb 115 | <% pull.diff_entries.nodes.each do |diff_entry| %> 116 | <%= diff_entry.path %> 117 | <% end %> 118 | 119 | <% if pull.errors[:diff_entries].any? %> 120 |

Sorry, we couldn't display all your diffs.

121 | <% end %> 122 | ``` 123 | 124 | ## See also 125 | 126 | * [graphql-js "path" field](https://github.com/graphql/graphql-js/blob/23592ad16868e06b1c003629759f905a77ab81a0/src/error/GraphQLError.js#L42-L48) 127 | * [GraphQL Specification section on "Error handling"](https://facebook.github.io/graphql/#sec-Error-handling) 128 | * [GraphQL Specification section on "Response errors"](https://facebook.github.io/graphql/#sec-Errors) 129 | -------------------------------------------------------------------------------- /guides/helpers.md: -------------------------------------------------------------------------------- 1 | # Helpers 2 | 3 | There is nothing special about ERB templates that can declare data dependencies. ERB templates are just Ruby functions and view helpers are just Ruby functions so they may also declare data dependencies. 4 | 5 | Helpers accessing many or nested object fields may declare a fragment for those requirements. 6 | 7 | ```ruby 8 | module MilestoneHelper 9 | # Define static query fragment for fetching data for helper. 10 | MilestoneProgressFragment = FooApp::Client.parse <<-'GRAPHQL' 11 | fragment on Milestone { 12 | closedIssueCount 13 | totalIssueCount 14 | } 15 | GRAPHQL 16 | 17 | def milestone_progress(milestone) 18 | milestone = MilestoneProgressFragment.new(milestone) 19 | percent = (milestone.closed_issue_count / milestone.total_issue_count) * 100 20 | content_tag(:span, "#{percent}%", class: "progress", style: "width: #{percent}%") 21 | end 22 | 23 | # A simpler version may use keyword arguments to define the functions 24 | # requirements. This avoids any dependency on the shape of data result 25 | # classes. This maybe a fine alternative if theres only a handful of 26 | # arguments. 27 | def milestone_progress(closed:, total:) 28 | percent = (closed / total) * 100 29 | content_tag(:span, "#{percent}%", class: "progress", style: "width: #{percent}%") 30 | end 31 | end 32 | ``` 33 | -------------------------------------------------------------------------------- /guides/heredoc.md: -------------------------------------------------------------------------------- 1 | # Heredoc style 2 | 3 | Prefer quoted heredoc style when defining GraphQL query strings. 4 | 5 | ```ruby 6 | # good 7 | FooQuery = <<-'GRAPHQL' 8 | { version } 9 | GRAPHQL 10 | ``` 11 | 12 | ```ruby 13 | # bad 14 | FooQuery = <<-GRAPHQL 15 | { version } 16 | GRAPHQL 17 | ``` 18 | 19 | Using a single quoted heredoc disables interpolation. GraphQL queries should not be constructed via string concatenate, especially at runtime. Interpolating user values into a query may lead to a "GraphQL injection" security vulnerability. Pass `variables:` instead of string interpolation. 20 | 21 | ```ruby 22 | # good 23 | FooQuery = <<-'GRAPHQL' 24 | query($id: ID!) { 25 | node(id: $id) { 26 | } 27 | } 28 | GRAPHQL 29 | query(FooQuery, variables: { id: id }) 30 | ``` 31 | 32 | ```ruby 33 | # bad 34 | FooQuery = <<-GRAPHQL 35 | query { 36 | node(id: "#{id}") { 37 | } 38 | } 39 | GRAPHQL 40 | query(FooQuery) 41 | ``` 42 | 43 | Bonus: Quoted heredocs syntax highlight look better in Atom. 44 | -------------------------------------------------------------------------------- /guides/implicitly-fetched-field-error.md: -------------------------------------------------------------------------------- 1 | # GraphQL::Client::ImplicitlyFetchedFieldError 2 | 3 | Similar to [`UnfetchedFieldError`](unfetched-field-error.md), but raised when trying to access a field on a GraphQL response type that happens to be fetched elsewhere by another fragment but not by the current fragment. The data is available, but isn't safe to rely on until it is explicitly added to the fragment. 4 | 5 | This protection is similar to [Relay's Data Masking feature](https://facebook.github.io/relay/docs/thinking-in-relay.html#data-masking). 6 | 7 | ## Parent Data Leak 8 | 9 | One source of these data leaks may come from a parent fragment fetching the data used down in a nested subview. 10 | 11 | For instance, a controller may fetch a user and include its `fullName`. 12 | 13 | ```ruby 14 | UserQuery = Client.parse <<-'GRAPHQL' 15 | query { 16 | user(name: "Josh") { 17 | fullName 18 | ...Views::Users::Show::User 19 | } 20 | } 21 | GRAPHQL 22 | ``` 23 | 24 | Many layers deep, a contact info helper might also too want to make use of the user's `fullName`. 25 | 26 | ```ruby 27 | UserFragment = Client.parse <<-'GRAPHQL' 28 | fragment on User { 29 | location 30 | # forgot fullName 31 | } 32 | GRAPHQL 33 | 34 | user = UserFragment.new(user) 35 | 36 | # ok as `location` was explicitly queried 37 | user.location 38 | 39 | # raises UnfetchedFieldError, missing fullName field in query 40 | user.full_name 41 | ``` 42 | 43 | In this case, the raw GraphQL will include both `location` and `fullName`: 44 | 45 | ```json 46 | { 47 | "user": { 48 | "fullName": "Joshua Peek", 49 | "location": "Chicago" 50 | } 51 | } 52 | ``` 53 | 54 | If the controller for some reason decides its doesn't care about `fullName` anymore and stops querying it, it will break the helper. The developer just looking at that controller file isn't going to know some other helper on the other side of the codebase still cares about `fullName`. 55 | 56 | Self contained functions should only safely rely on data dependencies they explicitly ask for. If both the controller and our helper explicitly state they both need `fullName`, that data will always be fetched even if the data requirements for one of the functions changes. 57 | 58 | ## Child Data Leak 59 | 60 | Similar to the parent data leak scenario, but occurs when a subview fetches data that our root view didn't explicitly ask for. 61 | 62 | ```erb 63 | <%graphql 64 | fragment User on User { 65 | fullName 66 | location 67 | } 68 | %> 69 | ``` 70 | 71 | ```ruby 72 | UserQuery = Client.parse <<-'GRAPHQL' 73 | query { 74 | user(name: "Josh") { 75 | ...Views::Users::Show::User 76 | } 77 | } 78 | GRAPHQL 79 | 80 | user = UserQuery.new(data) 81 | 82 | # raises UnfetchedFieldError, missing fullName field in query 83 | user.full_name 84 | ``` 85 | 86 | The raw flattened data will include `fullName` just like the previous example. But again, we should depend on our `UserQuery` always having `fullName` available show the subview be modified. 87 | 88 | ## See Also 89 | 90 | * [Over-fetching and under-fetching](over-under-fetching.md) 91 | * [Relay Data Masking](https://facebook.github.io/relay/docs/thinking-in-relay.html#data-masking) 92 | -------------------------------------------------------------------------------- /guides/local-queries.md: -------------------------------------------------------------------------------- 1 | # Local Queries 2 | 3 | Nothing says GraphQL queries need to go over wires. 4 | 5 | If your frontend and backend code happen to be running in one big monolith application, you can simply point your client at your server's defined schema and execute queries in the same process. 6 | 7 | ```ruby 8 | # server.rb 9 | require "graphql" 10 | 11 | module Server 12 | QueryType = GraphQL::ObjectType.define do 13 | name "Query" 14 | field :version, !types.Int 15 | end 16 | 17 | Schema = GraphQL::Schema.define(query: QueryType) 18 | end 19 | ``` 20 | 21 | See more about [defining a server schema on the graphql-ruby guide](https://github.com/rmosolgo/graphql-ruby/blob/master/guides/defining_your_schema.md). 22 | 23 | ```ruby 24 | # client.rb 25 | require "server" 26 | require "graphql/client" 27 | 28 | Client = GraphQL::Client.new(schema: Server::Schema, execute: Server::Schema) 29 | 30 | Query = Client.parse <<-'GRAPHQL' 31 | query { 32 | version 33 | } 34 | GRAPHQL 35 | 36 | result = Client.query(Query) 37 | puts result.data.version 38 | ``` 39 | -------------------------------------------------------------------------------- /guides/over-under-fetching.md: -------------------------------------------------------------------------------- 1 | # Over-fetching and under-fetching 2 | 3 | In a dynamic language like Ruby, over and under fetching are two common pitfalls. 4 | 5 | ## Over-fetching 6 | 7 | Over-fetching occurs when additional fields are declared in a fragment but are not actually used in the template. This will likely happen when template code is modified to remove usage of a certain field. 8 | 9 | ```diff 10 | <%graphql 11 | fragment Issue on Issue { 12 | title 13 | } 14 | %> 15 | -

<%= issue["title"] %>

16 | ``` 17 | 18 | If the fragment is not updated along with this changed, the property will still be fetched when we no longer need it. A simple `title` field may not be a big deal in practice but this property could have been a more expensive nested data tree. 19 | 20 | ## Under-fetching 21 | 22 | Under-fetching occurs when fields are not declared in a fragment but are used in the template. This missing data will likely surface as a `NoFieldError` or `nil` value. 23 | 24 | Worse, there may be a latent under-fetch bug when a template does not declare a data dependency but appears to be working because its caller happens to fetch the correct data upstream. But when this same template is rendered from a different path, it errors on missing data. 25 | 26 | ```erb 27 | <%graphql 28 | fragment IssueHeader on Issue { 29 | title 30 | # forgot to declare 31 | # author { login } 32 | } 33 | %> 34 | <%= issue["title"] %> 35 | by <%= issue["author"]["login"] %> 36 | ``` 37 | 38 | ```erb 39 | <%graphql 40 | fragment Issue on Issue { 41 | number 42 | # parent view happens to include author.login the child will need 43 | author { login } 44 | ...Views::Issues::Issue::IssueHeader 45 | } 46 | %> 47 | 48 | <%= render "issue/header", issue: issue %> 49 | ``` 50 | 51 | The parent view in this case may drop its `author` dependency and break the child view. 52 | 53 | ```diff 54 | - # parent view happens to include author.login the child will need 55 | - author { login } 56 | ``` 57 | 58 | ## Data Masking 59 | 60 | To avoid this under-fetching issue, views do not access raw JSON data directly. Instead they use a Ruby struct-like object derived from the fragment. 61 | 62 | The `Views::Issues::Show::Issue.new` object wraps the raw data hash with accessors that are explicitly declared by the current view. Even though `issue["number"]` is fetched and exposed to the parent view, `issue.number` here will raise `NoFieldError`. 63 | 64 | ```erb 65 | <% issue = Views::Issues::Show::Issue.new(issue) %> 66 | <%= issue.title %> 67 | by <%= issue.author.login %> 68 | ``` 69 | 70 | ## See Also 71 | 72 | * [`ImplicitlyFetchedFieldError`](implicitly-fetched-field-error.md) 73 | * [Relay Data Masking](https://facebook.github.io/relay/docs/thinking-in-relay.html#data-masking) 74 | -------------------------------------------------------------------------------- /guides/rails-configuration.md: -------------------------------------------------------------------------------- 1 | # Rails Configuration 2 | 3 | Checkout the [GitHub GraphQL Rails example application](https://github.com/github/github-graphql-rails-example). 4 | 5 | ## Setup 6 | 7 | Assumes your application is named `Foo`. 8 | 9 | ### Add graphql-client to your Gemfile 10 | 11 | ```ruby 12 | gem 'graphql-client' 13 | ``` 14 | 15 | ### Configure 16 | 17 | This part is temporarily a mess due to railtie and application initialization order. 18 | 19 | ```ruby 20 | require "graphql/client/railtie" 21 | require "graphql/client/http" 22 | 23 | module Foo 24 | HTTP = GraphQL::Client::HTTP.new("https://foo.com/") 25 | # TODO: Rails.root isn't available yet :( 26 | Client = GraphQL::Client.new(schema: "db/schema.json", execute: HTTP) 27 | 28 | class Application < Rails::Application 29 | # Set config.graphql.client to configure the client instance ERB templates 30 | # will be parsed against. 31 | # 32 | # client must be set before initializers run. config/initializers/* 33 | # are ran after graphql-client initializers so thats too late. 34 | config.graphql.client = Client 35 | end 36 | end 37 | ``` 38 | 39 | ### Define a schema updater rake task 40 | 41 | _(May eventually be part of `graphql/railtie`)_ 42 | 43 | ```ruby 44 | namespace :schema do 45 | task :update do 46 | GraphQL::Client.dump_schema(Foo::HTTP, "db/schema.json") 47 | end 48 | end 49 | ``` 50 | 51 | Its recommended you check in the downloaded schema. Periodically refetch and keep up-to-date. 52 | 53 | ```sh 54 | $ bin/rake schema:update 55 | $ git add db/schema.json 56 | ``` 57 | -------------------------------------------------------------------------------- /guides/remote-queries.md: -------------------------------------------------------------------------------- 1 | # Remote Queries 2 | 3 | In most GraphQL setups, the client will need to make some sort of network request to a remote server. Which will likely be HTTPS, which is why `GraphQL::Client` bundles a [basic HTTP adapter wrapping `Net::HTTP`](https://github.com/github/graphql-client/blob/master/lib/graphql/client/http.rb). 4 | 5 | The stock `GraphQL::Client::HTTP` assumes your to a [express-graphql compatible endpoint](https://github.com/graphql/express-graphql#http-usage). There is no formal definition of what the HTTP endpoint should look like in the GraphQL spec itself, but the [express-graphql](https://github.com/graphql/express-graphql) service has become the de facto standard. It just assumes the endpoint accepts the following parameters: `"query"`, `"variables"` and `"operationName"`. 6 | 7 | If you need to customize this, writing an adapter is very straight forward. 8 | 9 | An execution adapter is any object that responds to `execute(document:, operation_name:, variables:, context:)`. 10 | 11 | To demonstrate using a network library other than `Net::HTTP`, here's a simplified HTTP adapter using the Faraday library. 12 | 13 | ```ruby 14 | require "faraday" 15 | 16 | class FaradayAdapter 17 | def self.execute(document:, operation_name:, variables:, context:) 18 | response = Faraday.post("http://graphql-swapi.parseapp.com/", { 19 | "query" => document.to_query_string, 20 | "operationName" => operation_name, 21 | "variables" => variables 22 | }) 23 | JSON.parse(response.body) 24 | end 25 | end 26 | ``` 27 | -------------------------------------------------------------------------------- /guides/templates.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | ERB templates access data in a similar way as traversing an ActiveRecord object graph. Simple object fields maybe accessed, as well as parent or child has-one and has-many associations. 4 | 5 | All data passing is done explicitly through `locals:` just as you would pass arguments to a function. By convention, this object may be a raw data `Hash` received from GraphQL or a wrapped Ruby struct-like object. For consistency, the argument should be casted into a nice Ruby friendly object first thing in the template. 6 | 7 | `app/views/issues/show.html.erb`: 8 | 9 | ```erb 10 | <%# cast issue data hash into ruby friendly struct %> 11 | <% issue = Issues::Show::Issue.new(issue) %> 12 | 13 |

<%= issue.repository.name %>: <%= issue.title %>

14 | <%= issue.body_html # bodyHTML is snakecasified %> 15 | by <%= issue.author.login %> 16 | 17 | <% issue.comments.each do |comment| %> 18 | <%# Pass comment to subview %> 19 | <%= render "issues/comment", comment: comment %> 20 | <% end %> 21 | ``` 22 | 23 | This is all pretty traditional Ruby and Rails so far. 24 | 25 | However, since the views can not access ActiveRecord objects directly anymore, a static query is defined inline `.erb` file declaring the views data dependencies. 26 | 27 | `app/views/issues/show.html.erb`: 28 | 29 | ```erb 30 | <%graphql 31 | fragment on Issue { 32 | title 33 | repository { 34 | name 35 | } 36 | bodyHTML 37 | author { 38 | login 39 | } 40 | comments { 41 | # issues/show is only concerned with rendering a collection of 42 | # comments, not the comment itself. However, we do need to statically 43 | # include the data dependencies of the issues/comment partial we 44 | # intend to render. 45 | ...Views::Issues::Comment::Comment 46 | } 47 | } 48 | %> 49 | ``` 50 | 51 | Our GraphQL fragment definition includes all the fields we want to access just in the `show.html.erb` file itself, nothing more, nothing less. 52 | 53 | However, we do render a subview and hand off a `comment`. Since we composed rendered calls, we'll need to compose our fragment query as well. This works by including the subview's `...Views::Issues::Comment::Comment` into the 54 | `comments` collection we requested. 55 | 56 | `app/views/issues/_comment.html.erb`: 57 | 58 | ```erb 59 | <%graphql 60 | fragment on Comment { 61 | bodyHTML 62 | author { 63 | login 64 | } 65 | } 66 | %> 67 | 68 | <%# cast comment data hash into ruby friendly struct %> 69 | <%# this casting also allows us to accessing any fields that were opaque to %> 70 | <%# our parent view. %> 71 | <% comment = Issues::Comment::CommentFragment.new(comment) %> 72 | 73 | <%= comment.body_html %> 74 | by <%= comment.author.login %> 75 | ``` 76 | 77 | ## Composing fragments 78 | 79 | ### Static 80 | 81 | Many views will always render a set of subviews. 82 | 83 | ```erb 84 |
85 |

<%= issue.title %>

86 | <%= render "issues/header", issue: issue %> 87 | <%= render "issues/body", issue: issue %> 88 |
89 | ``` 90 | 91 | The fragment should declare all the data dependencies used by just this partial. In this case, only the issue's `title` is explicitly used, then include any subview fragments. 92 | 93 | ```erb 94 | <%graphql 95 | fragment IssueFragment on Issue { 96 | title 97 | ...Views::Issues::Header::Issue 98 | ...Views::Issues::Body::Issue 99 | } 100 | %> 101 | ``` 102 | 103 | ### Looping over a collection 104 | 105 | ```erb 106 |

<%= issue.title %>

107 | 108 | <% issue.comments.each do |comment| %> 109 | <%= render "issues/comment", comment: comment %> 110 | <% end %> 111 | ``` 112 | 113 | The fragment declares the view's own data dependencies as before. As well as the `comments` collection. Since a comment is passed to the `issues/comment` partial, not the issue, we'll include the fragment inside `comments { ... }`. 114 | 115 | ```erb 116 | <%graphql 117 | fragment IssueFragment on Issue { 118 | title 119 | comments { 120 | ...Views::Issues::Comment::CommentFragment 121 | } 122 | } 123 | %> 124 | ``` 125 | 126 | ### Branch on associated data presence 127 | 128 | ```erb 129 |

<%= issue.title %>

130 | 131 | <% if milestone = issue.milestone %> 132 | <%= render "issues/milestone", milestone: milestone 133 | <% end %> 134 | ``` 135 | 136 | Similar to embedding a collection's fragment, the partial defines the data for the milestone itself, not the issue. We include the fragment in the `milestone { ... }` connection. 137 | 138 | ```erb 139 | <%graphql 140 | fragment Issue on Issue { 141 | title 142 | milestone { 143 | ...Views::Issues::Milestone::Milestone 144 | } 145 | } 146 | %> 147 | ``` 148 | 149 | ### Branch on arbitrary flag 150 | 151 | More generally, UI may only be visible if a flag is set on the data object. 152 | 153 | ```erb 154 | <% if comment.editable_by_viewer? %> 155 | <%= render "issues/comment_edit_toolbar", comment: comment 156 | <% end %> 157 | 158 | <%= comment.body_html %> 159 | ``` 160 | 161 | Since the view may conditionally need the edit toolbars data, the view's fragment must always be included. This is an acceptable place where overfetching data is okay. 162 | 163 | ```erb 164 | <%graphql 165 | fragment Comment on Comment { 166 | bodyHTML 167 | editableByViewer 168 | ...Views::Issues::CommentEditToolbar::Comment 169 | } 170 | ``` 171 | 172 | ## See also 173 | 174 | [github-graphql-rails-example](https://github.com/github/github-graphql-rails-example) template examples: 175 | 176 | * [app/views/repositories/index.html.erb](https://github.com/github/github-graphql-rails-example/blob/master/app/views/repositories/index.html.erb) shows the root template's listing query and composition over subviews. 177 | * [app/views/repositories/\_repositories.html.erb](https://github.com/github/github-graphql-rails-example/blob/master/app/views/repositories/_repositories.html.erb) makes use of GraphQL connections to show the first couple items and a "load more" button. 178 | * [app/views/repositories/show.html.erb](https://github.com/github/github-graphql-rails-example/blob/master/app/views/repositories/show.html.erb) shows the root template for the repository show page. 179 | -------------------------------------------------------------------------------- /guides/unfetched-field-error.md: -------------------------------------------------------------------------------- 1 | # GraphQL::Client::UnfetchedFieldError 2 | 3 | Raised when trying to access a field on a GraphQL response type which hasn't been explicitly queried. 4 | 5 | ```graphql 6 | type User { 7 | firstName: String! 8 | lastName: String! 9 | } 10 | ``` 11 | 12 | ```ruby 13 | UserFragment = Client.parse <<-'GRAPHQL' 14 | fragment on User { 15 | firstName 16 | } 17 | GRAPHQL 18 | 19 | user = UserFragment.new(user) 20 | 21 | # ok 22 | user.first_name 23 | 24 | # raises UnfetchedFieldError, missing lastName field in query 25 | user.last_name 26 | ``` 27 | 28 | GraphQL requires all fields to be explicitly queried. Just add `lastName` to your query and be on your way. 29 | 30 | ```ruby 31 | UserFragment = Client.parse <<-'GRAPHQL' 32 | fragment on User { 33 | firstName 34 | lastName 35 | } 36 | GRAPHQL 37 | 38 | user = UserFragment.new(user) 39 | 40 | # now ok! 41 | user.last_name 42 | ``` 43 | -------------------------------------------------------------------------------- /guides/unimplemented-field-error.md: -------------------------------------------------------------------------------- 1 | # GraphQL::Client::UnimplementedFieldError 2 | 3 | Raised when trying access a field on a GraphQL response type which isn't defined by the schema. 4 | 5 | ```graphql 6 | type User { 7 | name: String! 8 | } 9 | ``` 10 | 11 | ```ruby 12 | UserFragment = Client.parse <<-'GRAPHQL' 13 | fragment on User { 14 | name 15 | } 16 | GRAPHQL 17 | 18 | user = UserFragment.new(user) 19 | 20 | # ok 21 | user.name 22 | 23 | # raises UnimplementedFieldError, no such field called nickname on User 24 | user.nickname 25 | ``` 26 | 27 | It's possible the method name may just be a typo of an existing field. 28 | 29 | Likely it's a field that you expected to be implemented but wasn't. If you own this schema, consider implementing it yourself! 30 | -------------------------------------------------------------------------------- /lib/graphql/client/collocated_enforcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/error" 3 | 4 | module GraphQL 5 | class Client 6 | 7 | # Collocation will not be enforced if a stack trace includes any of these gems. 8 | WHITELISTED_GEM_NAMES = %w{pry byebug} 9 | 10 | # Raised when method is called from outside the expected file scope. 11 | class NonCollocatedCallerError < Error; end 12 | 13 | # Enforcements collocated object access best practices. 14 | module CollocatedEnforcement 15 | extend self 16 | 17 | # Public: Ignore collocated caller enforcement for the scope of the block. 18 | def allow_noncollocated_callers 19 | Thread.current[:query_result_caller_location_ignore] = true 20 | yield 21 | ensure 22 | Thread.current[:query_result_caller_location_ignore] = nil 23 | end 24 | 25 | def verify_collocated_path(location, path, method = "method") 26 | return yield if Thread.current[:query_result_caller_location_ignore] 27 | 28 | if (location.path != path) && !(WHITELISTED_GEM_NAMES.any? { |g| location.path.include?("gems/#{g}") }) 29 | error = NonCollocatedCallerError.new("#{method} was called outside of '#{path}' https://github.com/github-community-projects/graphql-client/blob/master/guides/collocated-call-sites.md") 30 | error.set_backtrace(caller(2)) 31 | raise error 32 | end 33 | 34 | begin 35 | Thread.current[:query_result_caller_location_ignore] = true 36 | yield 37 | ensure 38 | Thread.current[:query_result_caller_location_ignore] = nil 39 | end 40 | end 41 | 42 | # Internal: Decorate method with collocated caller enforcement. 43 | # 44 | # mod - Target Module/Class 45 | # methods - Array of Symbol method names 46 | # path - String filename to assert calling from 47 | # 48 | # Returns nothing. 49 | def enforce_collocated_callers(mod, methods, path) 50 | mod.prepend(Module.new do 51 | methods.each do |method| 52 | define_method(method) do |*args, &block| 53 | location = caller_locations(1, 1)[0] 54 | CollocatedEnforcement.verify_collocated_path(location, path, method) do 55 | super(*args, &block) 56 | end 57 | end 58 | end 59 | end) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/graphql/client/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql" 4 | require "graphql/client/collocated_enforcement" 5 | require "graphql/client/schema/object_type" 6 | require "graphql/client/schema/possible_types" 7 | require "set" 8 | 9 | module GraphQL 10 | class Client 11 | # Definitions are constructed by Client.parse and wrap a parsed AST of the 12 | # query string as well as hold references to any external query definition 13 | # dependencies. 14 | # 15 | # Definitions MUST be assigned to a constant. 16 | class Definition < Module 17 | def self.for(ast_node:, **kargs) 18 | case ast_node 19 | when Language::Nodes::OperationDefinition 20 | OperationDefinition.new(ast_node: ast_node, **kargs) 21 | when Language::Nodes::FragmentDefinition 22 | FragmentDefinition.new(ast_node: ast_node, **kargs) 23 | else 24 | raise TypeError, "expected node to be a definition type, but was #{ast_node.class}" 25 | end 26 | end 27 | 28 | def initialize(client:, document:, source_document:, ast_node:, source_location:) 29 | @client = client 30 | @document = document 31 | @source_document = source_document 32 | @definition_node = ast_node 33 | @source_location = source_location 34 | 35 | definition_type = case ast_node 36 | when GraphQL::Language::Nodes::OperationDefinition 37 | case ast_node.operation_type 38 | when "mutation" 39 | @client.schema.mutation 40 | when "subscription" 41 | @client.schema.subscription 42 | when "query", nil 43 | @client.schema.query 44 | else 45 | raise "Unexpected operation_type: #{ast_node.operation_type}" 46 | end 47 | when GraphQL::Language::Nodes::FragmentDefinition 48 | @client.get_type(ast_node.type.name) 49 | else 50 | raise "Unexpected ast_node: #{ast_node}" 51 | end 52 | 53 | @schema_class = client.types.define_class(self, [ast_node], definition_type) 54 | 55 | # Clear cache only needed during initialization 56 | @indexes = nil 57 | end 58 | 59 | # Internal: Get associated owner GraphQL::Client instance. 60 | attr_reader :client 61 | 62 | # Internal root schema class for definition. Returns 63 | # GraphQL::Client::Schema::ObjectType or 64 | # GraphQL::Client::Schema::PossibleTypes. 65 | attr_reader :schema_class 66 | 67 | # Internal: Get underlying operation or fragment definition AST node for 68 | # definition. 69 | # 70 | # Returns OperationDefinition or FragmentDefinition object. 71 | attr_reader :definition_node 72 | 73 | # Internal: Get original document that created this definition, without 74 | # any additional dependencies. 75 | # 76 | # Returns GraphQL::Language::Nodes::Document. 77 | attr_reader :source_document 78 | 79 | # Public: Global name of definition in client document. 80 | # 81 | # Returns a GraphQL safe name of the Ruby constant String. 82 | # 83 | # "Users::UserQuery" #=> "Users__UserQuery" 84 | # 85 | # Returns String. 86 | def definition_name 87 | return @definition_name if defined?(@definition_name) 88 | 89 | if name 90 | @definition_name = name.gsub("::", "__").freeze 91 | else 92 | "#{self.class.name}_#{object_id}".gsub("::", "__").freeze 93 | end 94 | end 95 | 96 | # Public: Get document with only the definitions needed to perform this 97 | # operation. 98 | # 99 | # Returns GraphQL::Language::Nodes::Document with one OperationDefinition 100 | # and any FragmentDefinition dependencies. 101 | attr_reader :document 102 | 103 | # Public: Returns the Ruby source filename and line number containing this 104 | # definition was not defined in Ruby. 105 | # 106 | # Returns Array pair of [String, Fixnum]. 107 | attr_reader :source_location 108 | 109 | def new(obj, errors = Errors.new) 110 | case schema_class 111 | when GraphQL::Client::Schema::PossibleTypes 112 | case obj 113 | when NilClass 114 | obj 115 | else 116 | cast_object(obj) 117 | end 118 | when GraphQL::Client::Schema::ObjectType::WithDefinition 119 | case obj 120 | when schema_class.klass 121 | if obj._definer == schema_class 122 | obj 123 | else 124 | cast_object(obj) 125 | end 126 | when nil 127 | nil 128 | when Hash 129 | schema_class.new(obj, errors) 130 | else 131 | cast_object(obj) 132 | end 133 | when GraphQL::Client::Schema::ObjectType 134 | case obj 135 | when nil, schema_class 136 | obj 137 | when Hash 138 | schema_class.new(obj, errors) 139 | else 140 | cast_object(obj) 141 | end 142 | else 143 | raise TypeError, "unexpected #{schema_class}" 144 | end 145 | end 146 | 147 | # Internal: Nodes AST indexes. 148 | def indexes 149 | @indexes ||= begin 150 | visitor = DefinitionVisitor.new(document) 151 | visitor.visit 152 | { definitions: visitor.definitions, spreads: visitor.spreads } 153 | end 154 | end 155 | 156 | class DefinitionVisitor < GraphQL::Language::Visitor 157 | attr_reader :spreads, :definitions 158 | 159 | def initialize(doc) 160 | super 161 | @spreads = {} 162 | @definitions = {} 163 | @current_definition = nil 164 | end 165 | 166 | def on_field(node, parent) 167 | @definitions[node] = @current_definition 168 | @spreads[node] = get_spreads(node) 169 | super 170 | end 171 | 172 | def on_fragment_definition(node, parent) 173 | @current_definition = node 174 | @definitions[node] = @current_definition 175 | @spreads[node] = get_spreads(node) 176 | super 177 | ensure 178 | @current_definition = nil 179 | end 180 | 181 | def on_operation_definition(node, parent) 182 | @current_definition = node 183 | @definitions[node] = @current_definition 184 | @spreads[node] = get_spreads(node) 185 | super 186 | ensure 187 | @current_definition = nil 188 | end 189 | 190 | def on_inline_fragment(node, parent) 191 | @definitions[node] = @current_definition 192 | super 193 | end 194 | 195 | private 196 | 197 | EMPTY_SET = Set.new.freeze 198 | 199 | def get_spreads(node) 200 | node_spreads = flatten_spreads(node).map(&:name) 201 | node_spreads.empty? ? EMPTY_SET : Set.new(node_spreads).freeze 202 | end 203 | 204 | def flatten_spreads(node) 205 | spreads = [] 206 | node.selections.each do |selection| 207 | case selection 208 | when Language::Nodes::FragmentSpread 209 | spreads << selection 210 | when Language::Nodes::InlineFragment 211 | spreads.concat(flatten_spreads(selection)) 212 | else 213 | # Do nothing, not a spread 214 | end 215 | end 216 | spreads 217 | end 218 | end 219 | 220 | private 221 | 222 | def cast_object(obj) 223 | if obj.class.is_a?(GraphQL::Client::Schema::ObjectType) 224 | unless obj._spreads.include?(definition_node.name) 225 | raise TypeError, "#{definition_node.name} is not included in #{obj.source_definition.name}" 226 | end 227 | schema_class.cast(obj.to_h, obj.errors) 228 | else 229 | raise TypeError, "unexpected #{obj.class}" 230 | end 231 | end 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/graphql/client/definition_variables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | 4 | module GraphQL 5 | class Client 6 | # Internal: Detect variables used in a definition. 7 | module DefinitionVariables 8 | # Internal: Detect all variables used in a given operation or fragment 9 | # definition. 10 | # 11 | # schema - A GraphQL::Schema 12 | # document - A GraphQL::Language::Nodes::Document to scan 13 | # definition_name - A String definition name. Defaults to anonymous definition. 14 | # 15 | # Returns a Hash[Symbol] to GraphQL::Type objects. 16 | def self.variables(schema, document, definition_name = nil) 17 | unless schema.is_a?(GraphQL::Schema) || (schema.is_a?(Class) && schema < GraphQL::Schema) 18 | raise TypeError, "expected schema to be a GraphQL::Schema, but was #{schema.class}" 19 | end 20 | 21 | unless document.is_a?(GraphQL::Language::Nodes::Document) 22 | raise TypeError, "expected document to be a GraphQL::Language::Nodes::Document, but was #{document.class}" 23 | end 24 | 25 | sliced_document = GraphQL::Language::DefinitionSlice.slice(document, definition_name) 26 | 27 | visitor = VariablesVisitor.new(sliced_document, schema: schema) 28 | visitor.visit 29 | visitor.variables 30 | end 31 | 32 | class VariablesVisitor < GraphQL::Language::Visitor 33 | prepend GraphQL::Client::TypeStack 34 | 35 | def initialize(*_args, **_kwargs) 36 | super 37 | @variables = {} 38 | end 39 | 40 | attr_reader :variables 41 | 42 | def on_variable_identifier(node, parent) 43 | if definition = @argument_definitions.last 44 | existing_type = @variables[node.name.to_sym] 45 | 46 | if existing_type && existing_type.unwrap != definition.type.unwrap 47 | raise GraphQL::Client::ValidationError, "$#{node.name} was already declared as #{existing_type.unwrap}, but was #{definition.type.unwrap}" 48 | elsif !(existing_type && existing_type.kind.non_null?) 49 | @variables[node.name.to_sym] = definition.type 50 | end 51 | end 52 | super 53 | end 54 | end 55 | 56 | # Internal: Detect all variables used in a given operation or fragment 57 | # definition. 58 | # 59 | # schema - A GraphQL::Schema 60 | # document - A GraphQL::Language::Nodes::Document to scan 61 | # definition_name - A String definition name. Defaults to anonymous definition. 62 | # 63 | # Returns a Hash[Symbol] to VariableDefinition objects. 64 | def self.operation_variables(schema, document, definition_name = nil) 65 | variables(schema, document, definition_name).map { |name, type| 66 | GraphQL::Language::Nodes::VariableDefinition.new(name: name.to_s, type: variable_node(type)) 67 | } 68 | end 69 | 70 | # Internal: Get AST node for GraphQL type. 71 | # 72 | # type - A GraphQL::Type 73 | # 74 | # Returns GraphQL::Language::Nodes::Type. 75 | def self.variable_node(type) 76 | case type.kind.name 77 | when "NON_NULL" 78 | GraphQL::Language::Nodes::NonNullType.new(of_type: variable_node(type.of_type)) 79 | when "LIST" 80 | GraphQL::Language::Nodes::ListType.new(of_type: variable_node(type.of_type)) 81 | else 82 | GraphQL::Language::Nodes::TypeName.new(name: type.graphql_name) 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/graphql/client/document_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client/type_stack" 4 | 5 | module GraphQL 6 | class Client 7 | # Internal: Use schema to detect definition and field types. 8 | module DocumentTypes 9 | class AnalyzeTypesVisitor < GraphQL::Language::Visitor 10 | prepend GraphQL::Client::TypeStack 11 | attr_reader :fields 12 | 13 | def initialize(*a, **kw) 14 | @fields = {} 15 | super 16 | end 17 | 18 | def on_operation_definition(node, _parent) 19 | @fields[node] = @object_types.last 20 | super 21 | end 22 | 23 | def on_fragment_definition(node, _parent) 24 | @fields[node] = @object_types.last 25 | super 26 | end 27 | 28 | def on_inline_fragment(node, _parent) 29 | @fields[node] = @object_types.last 30 | super 31 | end 32 | 33 | def on_field(node, _parent) 34 | @fields[node] = @field_definitions.last.type 35 | super 36 | end 37 | end 38 | 39 | # Internal: Detect all types used in a given document 40 | # 41 | # schema - A GraphQL::Schema 42 | # document - A GraphQL::Language::Nodes::Document to scan 43 | # 44 | # Returns a Hash[Language::Nodes::Node] to GraphQL::Type objects. 45 | def self.analyze_types(schema, document) 46 | unless schema.is_a?(GraphQL::Schema) || (schema.is_a?(Class) && schema < GraphQL::Schema) 47 | raise TypeError, "expected schema to be a GraphQL::Schema, but was #{schema.class}" 48 | end 49 | 50 | unless document.is_a?(GraphQL::Language::Nodes::Document) 51 | raise TypeError, "expected schema to be a GraphQL::Language::Nodes::Document, but was #{document.class}" 52 | end 53 | 54 | visitor = AnalyzeTypesVisitor.new(document, schema: schema) 55 | visitor.visit 56 | visitor.fields 57 | rescue StandardError => err 58 | if err.is_a?(TypeError) 59 | raise 60 | end 61 | # FIXME: TypeStack my crash on invalid documents 62 | visitor.fields 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/graphql/client/erb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "action_view" 3 | require "logger" 4 | 5 | module GraphQL 6 | class Client 7 | begin 8 | require "action_view/template/handlers/erb/erubi" 9 | rescue LoadError 10 | require "graphql/client/erubis_enhancer" 11 | 12 | # Public: Extended Erubis implementation that supports GraphQL static 13 | # query sections. 14 | # 15 | # <%graphql 16 | # query GetVersion { 17 | # version 18 | # } 19 | # %> 20 | # <%= data.version %> 21 | # 22 | # Configure ActionView's default ERB implementation to use this class. 23 | # 24 | # ActionView::Template::Handlers::ERB.erb_implementation = GraphQL::Client::Erubis 25 | # 26 | class ERB < ActionView::Template::Handlers::Erubis 27 | include ErubisEnhancer 28 | end 29 | else 30 | require "graphql/client/erubi_enhancer" 31 | 32 | # Public: Extended Erubis implementation that supports GraphQL static 33 | # query sections. 34 | # 35 | # <%graphql 36 | # query GetVerison { 37 | # version 38 | # } 39 | # %> 40 | # <%= data.version %> 41 | # 42 | # Configure ActionView's default ERB implementation to use this class. 43 | # 44 | # ActionView::Template::Handlers::ERB.erb_implementation = GraphQL::Client::Erubi 45 | # 46 | class ERB < ActionView::Template::Handlers::ERB::Erubi 47 | include ErubiEnhancer 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/graphql/client/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module GraphQL 3 | class Client 4 | # Public: Abstract base class for all errors raised by GraphQL::Client. 5 | class Error < StandardError 6 | end 7 | 8 | class InvariantError < Error 9 | end 10 | 11 | class ImplicitlyFetchedFieldError < NoMethodError 12 | end 13 | 14 | class UnfetchedFieldError < NoMethodError 15 | end 16 | 17 | class UnimplementedFieldError < NoMethodError 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/graphql/client/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/hash_with_indifferent_access" 3 | 4 | module GraphQL 5 | class Client 6 | # Public: Collection of errors associated with GraphQL object type. 7 | # 8 | # Inspired by ActiveModel::Errors. 9 | class Errors 10 | include Enumerable 11 | 12 | # Internal: Normalize GraphQL Error "path" ensuring the path exists. 13 | # 14 | # Records "normalizedPath" value to error object. 15 | # 16 | # data - Hash of response data 17 | # errors - Array of error Hashes 18 | # 19 | # Returns nothing. 20 | def self.normalize_error_paths(data = nil, errors = []) 21 | errors.each do |error| 22 | path = ["data"] 23 | current = data 24 | error["path"].to_a.each do |key| 25 | break unless current 26 | path << key 27 | current = current[key] 28 | end 29 | error["normalizedPath"] = path 30 | end 31 | errors 32 | end 33 | 34 | # Internal: Initialize from collection of errors. 35 | # 36 | # errors - Array of GraphQL Hash error objects 37 | # path - Array of String|Integer fields to data 38 | # all - Boolean flag if all nested errors should be available 39 | def initialize(errors = [], path = [], all = false) 40 | @ast_path = path 41 | @all = all 42 | @raw_errors = errors 43 | end 44 | 45 | # Public: Return collection of all nested errors. 46 | # 47 | # data.errors[:node] 48 | # data.errors.all[:node] 49 | # 50 | # Returns Errors collection. 51 | def all 52 | if @all 53 | self 54 | else 55 | self.class.new(@raw_errors, @ast_path, true) 56 | end 57 | end 58 | 59 | # Internal: Return collection of errors for a given subfield. 60 | # 61 | # data.errors.filter_by_path("node") 62 | # 63 | # Returns Errors collection. 64 | def filter_by_path(field) 65 | self.class.new(@raw_errors, @ast_path + [field], @all) 66 | end 67 | 68 | # Public: Access Hash of error messages. 69 | # 70 | # data.errors.messages["node"] 71 | # data.errors.messages[:node] 72 | # 73 | # Returns HashWithIndifferentAccess. 74 | def messages 75 | return @messages if defined? @messages 76 | 77 | messages = {} 78 | 79 | details.each do |field, errors| 80 | messages[field] ||= [] 81 | errors.each do |error| 82 | messages[field] << error.fetch("message") 83 | end 84 | end 85 | 86 | @messages = HashWithIndifferentAccess.new(messages) 87 | end 88 | 89 | # Public: Access Hash of error objects. 90 | # 91 | # data.errors.details["node"] 92 | # data.errors.details[:node] 93 | # 94 | # Returns HashWithIndifferentAccess. 95 | def details 96 | return @details if defined? @details 97 | 98 | details = {} 99 | 100 | @raw_errors.each do |error| 101 | path = error.fetch("normalizedPath", []) 102 | matched_path = @all ? path[0, @ast_path.length] : path[0...-1] 103 | next unless @ast_path == matched_path 104 | 105 | field = path[@ast_path.length] 106 | next unless field 107 | 108 | details[field] ||= [] 109 | details[field] << error 110 | end 111 | 112 | @details = HashWithIndifferentAccess.new(details) 113 | end 114 | 115 | # Public: When passed a symbol or a name of a field, returns an array of 116 | # errors for the method. 117 | # 118 | # data.errors[:node] # => ["couldn't find node by id"] 119 | # data.errors['node'] # => ["couldn't find node by id"] 120 | # 121 | # Returns Array of errors. 122 | def [](key) 123 | messages.fetch(key, []) 124 | end 125 | 126 | # Public: Iterates through each error key, value pair in the error 127 | # messages hash. Yields the field and the error for that attribute. If the 128 | # field has more than one error message, yields once for each error 129 | # message. 130 | def each 131 | return enum_for(:each) unless block_given? 132 | messages.each_key do |field| 133 | messages[field].each { |error| yield field, error } 134 | end 135 | end 136 | 137 | # Public: Check if there are any errors on a given field. 138 | # 139 | # data.errors.messages # => {"node"=>["couldn't find node by id", "unauthorized"]} 140 | # data.errors.include?("node") # => true 141 | # data.errors.include?("version") # => false 142 | # 143 | # Returns true if the error messages include an error for the given field, 144 | # otherwise false. 145 | def include?(field) 146 | self[field].any? 147 | end 148 | alias has_key? include? 149 | alias key? include? 150 | 151 | # Public: Count the number of errors on object. 152 | # 153 | # data.errors.messages # => {"node"=>["couldn't find node by id", "unauthorized"]} 154 | # data.errors.size # => 2 155 | # 156 | # Returns the number of error messages. 157 | def size 158 | values.flatten.size 159 | end 160 | alias count size 161 | 162 | # Public: Check if there are no errors on object. 163 | # 164 | # data.errors.messages # => {"node"=>["couldn't find node by id"]} 165 | # data.errors.empty? # => false 166 | # 167 | # Returns true if no errors are found, otherwise false. 168 | def empty? 169 | size.zero? 170 | end 171 | alias blank? empty? 172 | 173 | # Public: Returns all message keys. 174 | # 175 | # data.errors.messages # => {"node"=>["couldn't find node by id"]} 176 | # data.errors.values # => ["node"] 177 | # 178 | # Returns Array of String field names. 179 | def keys 180 | messages.keys 181 | end 182 | 183 | # Public: Returns all message values. 184 | # 185 | # data.errors.messages # => {"node"=>["couldn't find node by id"]} 186 | # data.errors.values # => [["couldn't find node by id"]] 187 | # 188 | # Returns Array of Array String messages. 189 | def values 190 | messages.values 191 | end 192 | 193 | # Public: Display console friendly representation of errors collection. 194 | # 195 | # Returns String. 196 | def inspect 197 | "#<#{self.class} @messages=#{messages.inspect} @details=#{details.inspect}>" 198 | end 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /lib/graphql/client/erubi_enhancer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | class Client 5 | # Public: Erubi enhancer that adds support for GraphQL static query sections. 6 | # 7 | # <%graphql 8 | # query GetVersion { 9 | # version 10 | # } 11 | # %> 12 | # <%= data.version %> 13 | # 14 | module ErubiEnhancer 15 | # Internal: Extend Erubi handler to simply ignore <%graphql sections. 16 | def initialize(input, *args) 17 | input = input.gsub(/<%graphql/, "<%#") 18 | super(input, *args) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/graphql/client/erubis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/erb" 3 | 4 | module GraphQL 5 | class Client 6 | Erubis = GraphQL::Client::ERB 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/graphql/client/erubis_enhancer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | class Client 5 | # Public: Erubis enhancer that adds support for GraphQL static query sections. 6 | # 7 | # <%graphql 8 | # query GetVersion { 9 | # version 10 | # } 11 | # %> 12 | # <%= data.version %> 13 | # 14 | module ErubisEnhancer 15 | # Internal: Extend Erubis handler to simply ignore <%graphql sections. 16 | def convert_input(src, input) 17 | input = input.gsub(/<%graphql/, "<%#") 18 | super(src, input) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/graphql/client/fragment_definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/definition" 4 | 5 | module GraphQL 6 | class Client 7 | # Specific fragment definition subtype. 8 | class FragmentDefinition < Definition 9 | def new(obj, *args) 10 | if obj.is_a?(Hash) 11 | raise TypeError, "constructing fragment wrapper from Hash is deprecated" 12 | end 13 | 14 | super 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/graphql/client/hash_with_indifferent_access.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "active_support/inflector" 3 | require "forwardable" 4 | 5 | module GraphQL 6 | class Client 7 | # Public: Implements a read only hash where keys can be accessed by 8 | # strings, symbols, snake or camel case. 9 | # 10 | # Also see ActiveSupport::HashWithIndifferentAccess. 11 | class HashWithIndifferentAccess 12 | extend Forwardable 13 | include Enumerable 14 | 15 | def initialize(hash = {}) 16 | @hash = hash 17 | @aliases = {} 18 | 19 | hash.each_key do |key| 20 | if key.is_a?(String) 21 | key_alias = ActiveSupport::Inflector.underscore(key) 22 | @aliases[key_alias] = key if key != key_alias 23 | end 24 | end 25 | 26 | freeze 27 | end 28 | 29 | def_delegators :@hash, :each, :empty?, :inspect, :keys, :length, :size, :to_h, :to_hash, :values 30 | 31 | def [](key) 32 | @hash[convert_value(key)] 33 | end 34 | 35 | def fetch(key, *args, &block) 36 | @hash.fetch(convert_value(key), *args, &block) 37 | end 38 | 39 | def key?(key) 40 | @hash.key?(convert_value(key)) 41 | end 42 | alias include? key? 43 | alias has_key? key? 44 | alias member? key? 45 | 46 | def each_key(&block) 47 | @hash.each_key { |key| yield convert_value(key) } 48 | end 49 | 50 | private 51 | 52 | def convert_value(key) 53 | case key 54 | when String, Symbol 55 | key = key.to_s 56 | @aliases.fetch(key, key) 57 | else 58 | key 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/graphql/client/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "json" 3 | require "net/http" 4 | require "uri" 5 | 6 | module GraphQL 7 | class Client 8 | # Public: Basic HTTP network adapter. 9 | # 10 | # GraphQL::Client.new( 11 | # execute: GraphQL::Client::HTTP.new("http://graphql-swapi.parseapp.com/") 12 | # ) 13 | # 14 | # Assumes GraphQL endpoint follows the express-graphql endpoint conventions. 15 | # https://github.com/graphql/express-graphql#http-usage 16 | # 17 | # Production applications should consider implementing their own network 18 | # adapter. This class exists for trivial stock usage and allows for minimal 19 | # request header configuration. 20 | class HTTP 21 | # Public: Create HTTP adapter instance for a single GraphQL endpoint. 22 | # 23 | # GraphQL::Client::HTTP.new("http://graphql-swapi.parseapp.com/") do 24 | # def headers(context) 25 | # { "User-Agent": "My Client" } 26 | # end 27 | # end 28 | # 29 | # uri - String endpoint URI 30 | # block - Optional block to configure class 31 | def initialize(uri, &block) 32 | @uri = URI.parse(uri) 33 | singleton_class.class_eval(&block) if block_given? 34 | end 35 | 36 | # Public: Parsed endpoint URI 37 | # 38 | # Returns URI. 39 | attr_reader :uri 40 | 41 | # Public: Extension point for subclasses to set custom request headers. 42 | # 43 | # Returns Hash of String header names and values. 44 | def headers(_context) 45 | {} 46 | end 47 | 48 | # Public: full reponse from last request 49 | # 50 | # Returns Hash. 51 | attr_reader :last_response 52 | 53 | # Public: Make an HTTP request for GraphQL query. 54 | # 55 | # Implements Client's "execute" adapter interface. 56 | # 57 | # document - The Query GraphQL::Language::Nodes::Document 58 | # operation_name - The String operation definition name 59 | # variables - Hash of query variables 60 | # context - An arbitrary Hash of values which you can access 61 | # 62 | # Returns { "data" => ... , "errors" => ... } Hash. 63 | def execute(document:, operation_name: nil, variables: {}, context: {}) 64 | request = Net::HTTP::Post.new(uri.request_uri) 65 | 66 | request.basic_auth(uri.user, uri.password) if uri.user || uri.password 67 | 68 | request["Accept"] = "application/json" 69 | request["Content-Type"] = "application/json" 70 | headers(context).each { |name, value| request[name] = value } 71 | 72 | body = {} 73 | body["query"] = document.to_query_string 74 | body["variables"] = variables if variables.any? 75 | body["operationName"] = operation_name if operation_name 76 | request.body = JSON.generate(body) 77 | 78 | response = connection.request(request) 79 | @last_response = response.to_hash 80 | case response 81 | when Net::HTTPOK, Net::HTTPBadRequest 82 | JSON.parse(response.body) 83 | else 84 | { "errors" => [{ "message" => "#{response.code} #{response.message}" }] } 85 | end 86 | end 87 | 88 | # Public: Extension point for subclasses to customize the Net:HTTP client 89 | # 90 | # Returns a Net::HTTP object 91 | def connection 92 | Net::HTTP.new(uri.host, uri.port).tap do |client| 93 | client.use_ssl = uri.scheme == "https" 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/graphql/client/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/errors" 3 | 4 | module GraphQL 5 | class Client 6 | # Public: Array wrapper for value returned from GraphQL List. 7 | class List < Array 8 | def initialize(values, errors = Errors.new) 9 | super(values) 10 | @errors = errors 11 | freeze 12 | end 13 | 14 | # Public: Return errors associated with list of data. 15 | # 16 | # Returns Errors collection. 17 | attr_reader :errors 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/graphql/client/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "active_support/log_subscriber" 3 | 4 | module GraphQL 5 | class Client 6 | # Public: Logger for "*.graphql" notification events. 7 | # 8 | # Logs GraphQL queries to Rails logger. 9 | # 10 | # UsersController::ShowQuery QUERY (123ms) 11 | # UsersController::UpdateMutation MUTATION (456ms) 12 | # 13 | # Enable GraphQL Client query logging. 14 | # 15 | # require "graphql/client/log_subscriber" 16 | # GraphQL::Client::LogSubscriber.attach_to :graphql 17 | # 18 | class LogSubscriber < ActiveSupport::LogSubscriber 19 | SHOULD_USE_KWARGS = private_instance_methods.include?(:mode_from) 20 | 21 | def query(event) 22 | logger.info do 23 | name = event.payload[:operation_name].gsub("__", "::") 24 | type = event.payload[:operation_type].upcase 25 | 26 | if SHOULD_USE_KWARGS 27 | color("#{name} #{type} (#{event.duration.round(1)}ms)", nil, bold: true) 28 | else 29 | color("#{name} #{type} (#{event.duration.round(1)}ms)", nil, true) 30 | end 31 | end 32 | 33 | logger.debug do 34 | event.payload[:document].to_query_string 35 | end 36 | end 37 | 38 | def error(event) 39 | logger.error do 40 | name = event.payload[:operation_name].gsub("__", "::") 41 | message = event.payload[:message] 42 | 43 | if SHOULD_USE_KWARGS 44 | color("#{name} ERROR: #{message}", nil, bold: true) 45 | else 46 | color("#{name} ERROR: #{message}", nil, true) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/graphql/client/operation_definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/definition" 4 | 5 | module GraphQL 6 | class Client 7 | # Specific operation definition subtype for queries, mutations or 8 | # subscriptions. 9 | class OperationDefinition < Definition 10 | # Public: Alias for definition name. 11 | alias operation_name definition_name 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/graphql/client/query_typename.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client/document_types" 4 | 5 | module GraphQL 6 | class Client 7 | # Internal: Insert __typename field selections into query. 8 | module QueryTypename 9 | # Internal: Insert __typename field selections into query. 10 | # 11 | # Skips known types when schema is provided. 12 | # 13 | # document - GraphQL::Language::Nodes::Document to modify 14 | # schema - Optional Map of GraphQL::Language::Nodes::Node to GraphQL::Type 15 | # 16 | # Returns the document with `__typename` added to it 17 | if GraphQL::Language::Nodes::AbstractNode.method_defined?(:merge) 18 | # GraphQL 1.9 introduces a new visitor class 19 | # and doesn't expose writer methods for node attributes. 20 | # So, use the node mutation API instead. 21 | class InsertTypenameVisitor < GraphQL::Language::Visitor 22 | def initialize(document, types:) 23 | @types = types 24 | super(document) 25 | end 26 | 27 | def add_typename(node, parent) 28 | type = @types[node] 29 | type = type && type.unwrap 30 | 31 | if (node.selections.any? && (type.nil? || type.kind.interface? || type.kind.union?)) || 32 | (node.selections.none? && (type && type.kind.object?)) 33 | names = QueryTypename.node_flatten_selections(node.selections).map { |s| s.respond_to?(:name) ? s.name : nil } 34 | names = Set.new(names.compact) 35 | 36 | if names.include?("__typename") 37 | yield(node, parent) 38 | else 39 | node_with_typename = node.merge(selections: [GraphQL::Language::Nodes::Field.new(name: "__typename")] + node.selections) 40 | yield(node_with_typename, parent) 41 | end 42 | else 43 | yield(node, parent) 44 | end 45 | end 46 | 47 | def on_operation_definition(node, parent) 48 | add_typename(node, parent) { |n, p| super(n, p) } 49 | end 50 | 51 | def on_field(node, parent) 52 | add_typename(node, parent) { |n, p| super(n, p) } 53 | end 54 | 55 | def on_fragment_definition(node, parent) 56 | add_typename(node, parent) { |n, p| super(n, p) } 57 | end 58 | end 59 | 60 | def self.insert_typename_fields(document, types: {}) 61 | visitor = InsertTypenameVisitor.new(document, types: types) 62 | visitor.visit 63 | visitor.result 64 | end 65 | 66 | else 67 | def self.insert_typename_fields(document, types: {}) 68 | on_selections = ->(node, _parent) do 69 | type = types[node] 70 | 71 | if node.selections.any? 72 | case type && type.unwrap 73 | when NilClass, GraphQL::InterfaceType, GraphQL::UnionType 74 | names = node_flatten_selections(node.selections).map { |s| s.respond_to?(:name) ? s.name : nil } 75 | names = Set.new(names.compact) 76 | 77 | unless names.include?("__typename") 78 | node.selections = [GraphQL::Language::Nodes::Field.new(name: "__typename")] + node.selections 79 | end 80 | end 81 | elsif type && type.unwrap.is_a?(GraphQL::ObjectType) 82 | node.selections = [GraphQL::Language::Nodes::Field.new(name: "__typename")] 83 | end 84 | end 85 | 86 | visitor = GraphQL::Language::Visitor.new(document) 87 | visitor[GraphQL::Language::Nodes::Field].leave << on_selections 88 | visitor[GraphQL::Language::Nodes::FragmentDefinition].leave << on_selections 89 | visitor[GraphQL::Language::Nodes::OperationDefinition].leave << on_selections 90 | visitor.visit 91 | 92 | document 93 | end 94 | end 95 | 96 | def self.node_flatten_selections(selections) 97 | selections.flat_map do |selection| 98 | case selection 99 | when GraphQL::Language::Nodes::Field 100 | selection 101 | when GraphQL::Language::Nodes::InlineFragment 102 | node_flatten_selections(selection.selections) 103 | else 104 | [] 105 | end 106 | end 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/graphql/client/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "rails/railtie" 5 | 6 | module GraphQL 7 | class Client 8 | # Optional Rails configuration for GraphQL::Client. 9 | # 10 | # Simply require this file to activate in the application. 11 | # 12 | # # config/application.rb 13 | # require "graphql/client/railtie" 14 | # 15 | class Railtie < Rails::Railtie 16 | config.graphql = ActiveSupport::OrderedOptions.new 17 | config.graphql.client = GraphQL::Client.new 18 | 19 | initializer "graphql.configure_log_subscriber" do |_app| 20 | require "graphql/client/log_subscriber" 21 | GraphQL::Client::LogSubscriber.attach_to :graphql 22 | end 23 | 24 | initializer "graphql.configure_erb_implementation" do |_app| 25 | require "graphql/client/erb" 26 | ActionView::Template::Handlers::ERB.erb_implementation = GraphQL::Client::ERB 27 | end 28 | 29 | initializer "graphql.configure_views_namespace" do |app| 30 | require "graphql/client/view_module" 31 | 32 | path = app.paths["app/views"].first 33 | 34 | # TODO: Accessing config.graphql.client during the initialization 35 | # process seems error prone. The application may reassign 36 | # config.graphql.client after this block is executed. 37 | client = config.graphql.client 38 | 39 | config.watchable_dirs[path] = [:erb] 40 | 41 | Object.const_set(:Views, Module.new do 42 | extend GraphQL::Client::ViewModule 43 | self.path = path 44 | self.client = client 45 | end) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/graphql/client/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/errors" 3 | 4 | module GraphQL 5 | class Client 6 | # Public: Abstract base class for GraphQL responses. 7 | # 8 | # https://facebook.github.io/graphql/#sec-Response-Format 9 | class Response 10 | # Public: Original JSON response hash returned from server. 11 | # 12 | # Returns Hash. 13 | attr_reader :original_hash 14 | alias_method :to_h, :original_hash 15 | alias_method :to_hash, :original_hash 16 | 17 | # Public: Wrapped ObjectType of data returned from the server. 18 | # 19 | # https://facebook.github.io/graphql/#sec-Data 20 | # 21 | # Returns instance of ObjectType subclass. 22 | attr_reader :data 23 | 24 | # Public: Get partial failures from response. 25 | # 26 | # https://facebook.github.io/graphql/#sec-Errors 27 | # 28 | # Returns Errors collection object with zero or more errors. 29 | attr_reader :errors 30 | 31 | # Public: Hash of server specific extension metadata. 32 | attr_reader :extensions 33 | 34 | # Public: Complete response hash returned from server. 35 | # 36 | # Returns Hash 37 | attr_reader :full_response 38 | 39 | # Internal: Initialize base class. 40 | def initialize(hash, data: nil, errors: Errors.new, extensions: {}, full_response: nil) 41 | @original_hash = hash 42 | @data = data 43 | @errors = errors 44 | @extensions = extensions 45 | @full_response = full_response 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/graphql/client/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql" 4 | require "graphql/client/schema/enum_type" 5 | require "graphql/client/schema/include_directive" 6 | require "graphql/client/schema/interface_type" 7 | require "graphql/client/schema/list_type" 8 | require "graphql/client/schema/non_null_type" 9 | require "graphql/client/schema/object_type" 10 | require "graphql/client/schema/scalar_type" 11 | require "graphql/client/schema/skip_directive" 12 | require "graphql/client/schema/union_type" 13 | 14 | module GraphQL 15 | class Client 16 | module Schema 17 | module ClassMethods 18 | def define_class(definition, ast_nodes, type) 19 | type_class = case type.kind.name 20 | when "NON_NULL" 21 | define_class(definition, ast_nodes, type.of_type).to_non_null_type 22 | when "LIST" 23 | define_class(definition, ast_nodes, type.of_type).to_list_type 24 | else 25 | get_class(type.graphql_name).define_class(definition, ast_nodes) 26 | end 27 | 28 | ast_nodes.each do |ast_node| 29 | ast_node.directives.each do |directive| 30 | if directive = self.directives[directive.name.to_sym] 31 | type_class = directive.new(type_class) 32 | end 33 | end 34 | end 35 | 36 | type_class 37 | end 38 | 39 | def get_class(type_name) 40 | const_get(normalize_type_name(type_name)) 41 | end 42 | 43 | def set_class(type_name, klass) 44 | class_name = normalize_type_name(type_name) 45 | 46 | if const_defined?(class_name, false) 47 | raise ArgumentError, 48 | "Can't define #{class_name} to represent type #{type_name} " \ 49 | "because it's already defined" 50 | end 51 | 52 | const_set(class_name, klass) 53 | end 54 | 55 | DIRECTIVES = { include: IncludeDirective, 56 | skip: SkipDirective }.freeze 57 | 58 | def directives 59 | DIRECTIVES 60 | end 61 | 62 | private 63 | 64 | def normalize_type_name(type_name) 65 | /\A[A-Z]/.match?(type_name) ? type_name : type_name.camelize 66 | end 67 | end 68 | 69 | def self.generate(schema, raise_on_unknown_enum_value: true) 70 | mod = Module.new 71 | mod.extend ClassMethods 72 | mod.define_singleton_method(:raise_on_unknown_enum_value) { raise_on_unknown_enum_value } 73 | 74 | cache = {} 75 | schema.types.each do |name, type| 76 | next if name.start_with?("__") 77 | if klass = class_for(schema, type, cache) 78 | klass.schema_module = mod 79 | mod.set_class(name, klass) 80 | end 81 | end 82 | 83 | mod 84 | end 85 | 86 | def self.class_for(schema, type, cache) 87 | return cache[type] if cache[type] 88 | 89 | case type.kind.name 90 | when "INPUT_OBJECT" 91 | nil 92 | when "SCALAR" 93 | cache[type] = ScalarType.new(type) 94 | when "ENUM" 95 | cache[type] = EnumType.new(type) 96 | when "LIST" 97 | cache[type] = class_for(schema, type.of_type, cache).to_list_type 98 | when "NON_NULL" 99 | cache[type] = class_for(schema, type.of_type, cache).to_non_null_type 100 | when "UNION" 101 | klass = cache[type] = UnionType.new(type) 102 | 103 | type.possible_types.each do |possible_type| 104 | possible_klass = class_for(schema, possible_type, cache) 105 | possible_klass.send :include, klass 106 | end 107 | 108 | klass 109 | when "INTERFACE" 110 | cache[type] = InterfaceType.new(type) 111 | when "OBJECT" 112 | klass = cache[type] = ObjectType.new(type) 113 | 114 | type.interfaces.each do |interface| 115 | klass.send :include, class_for(schema, interface, cache) 116 | end 117 | # Legacy objects have `.all_fields` 118 | all_fields = type.respond_to?(:all_fields) ? type.all_fields : type.fields.values 119 | all_fields.each do |field| 120 | klass.fields[field.name.to_sym] = class_for(schema, field.type, cache) 121 | end 122 | 123 | klass 124 | else 125 | raise TypeError, "unexpected #{type.class} (#{type.inspect})" 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/base_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | class Client 5 | module Schema 6 | module BaseType 7 | # Public: Get associated GraphQL::BaseType with for this class. 8 | attr_reader :type 9 | 10 | # Internal: Get owner schema Module container. 11 | attr_accessor :schema_module 12 | 13 | # Internal: Cast JSON value to wrapped value. 14 | # 15 | # value - JSON value 16 | # errors - Errors instance 17 | # 18 | # Returns BaseType instance. 19 | def cast(value, errors) 20 | raise NotImplementedError, "subclasses must implement #cast(value, errors)" 21 | end 22 | 23 | # Internal: Get non-nullable wrapper of this type class. 24 | # 25 | # Returns NonNullType instance. 26 | def to_non_null_type 27 | @null_type ||= NonNullType.new(self) 28 | end 29 | 30 | # Internal: Get list wrapper of this type class. 31 | # 32 | # Returns ListType instance. 33 | def to_list_type 34 | @list_type ||= ListType.new(self) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/enum_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/error" 4 | require "graphql/client/schema/base_type" 5 | 6 | module GraphQL 7 | class Client 8 | module Schema 9 | class EnumType < Module 10 | include BaseType 11 | 12 | class EnumValue < String 13 | def initialize(obj, enum_value, enum) 14 | super(obj) 15 | @enum_value = enum_value 16 | @enum = enum 17 | end 18 | 19 | def unknown_enum_value? 20 | false 21 | end 22 | 23 | def respond_to_missing?(method_name, include_private = false) 24 | if method_name[-1] == "?" && @enum.include?(method_name[0..-2]) 25 | true 26 | else 27 | super 28 | end 29 | end 30 | 31 | def method_missing(method_name, *args) 32 | if method_name[-1] == "?" 33 | queried_value = method_name[0..-2] 34 | if @enum.include?(queried_value) 35 | raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" unless args.empty? 36 | return @enum_value == queried_value 37 | end 38 | end 39 | 40 | super 41 | end 42 | end 43 | 44 | class UnexpectedEnumValue < String 45 | def unknown_enum_value? 46 | true 47 | end 48 | end 49 | 50 | # Internal: Construct enum wrapper from another GraphQL::EnumType. 51 | # 52 | # type - GraphQL::EnumType instance 53 | def initialize(type) 54 | unless type.kind.enum? 55 | raise "expected type to be an Enum, but was #{type.class}" 56 | end 57 | 58 | @type = type 59 | @values = {} 60 | 61 | all_values = type.values.keys 62 | comparison_set = all_values.map { |s| -s.downcase }.to_set 63 | 64 | all_values.each do |value| 65 | str = EnumValue.new(-value, -value.downcase, comparison_set).freeze 66 | const_set(value, str) if value =~ /^[A-Z]/ 67 | @values[str.to_s] = str 68 | end 69 | 70 | @values.freeze 71 | end 72 | 73 | def define_class(definition, ast_nodes) 74 | self 75 | end 76 | 77 | def [](value) 78 | @values[value] 79 | end 80 | 81 | # Internal: Cast JSON value to the enumeration's corresponding constant string instance 82 | # with the convenience predicate methods. 83 | # 84 | # values - JSON value 85 | # errors - Errors instance 86 | # 87 | # Returns String or nil. 88 | def cast(value, _errors = nil) 89 | case value 90 | when String 91 | if @values.key?(value) 92 | @values[value] 93 | elsif schema_module.raise_on_unknown_enum_value 94 | raise Error, "unexpected enum value #{value}" 95 | else 96 | UnexpectedEnumValue.new(value).freeze 97 | end 98 | when NilClass 99 | value 100 | else 101 | raise InvariantError, "expected value to be a String, but was #{value.class}" 102 | end 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/include_directive.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/schema/base_type" 4 | 5 | module GraphQL 6 | class Client 7 | module Schema 8 | class IncludeDirective 9 | include BaseType 10 | 11 | # Internal: Construct list wrapper from other BaseType. 12 | # 13 | # of_klass - BaseType instance 14 | def initialize(of_klass) 15 | unless of_klass.is_a?(BaseType) 16 | raise TypeError, "expected #{of_klass.inspect} to be a #{BaseType}" 17 | end 18 | 19 | @of_klass = of_klass 20 | end 21 | 22 | # Internal: Get wrapped klass. 23 | # 24 | # Returns BaseType instance. 25 | attr_reader :of_klass 26 | 27 | # Internal: Cast JSON value to wrapped value. 28 | # 29 | # values - JSON value 30 | # errors - Errors instance 31 | # 32 | # Returns List instance or nil. 33 | def cast(value, errors) 34 | case value 35 | when NilClass 36 | nil 37 | else 38 | of_klass.cast(value, errors) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/interface_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/schema/possible_types" 4 | 5 | module GraphQL 6 | class Client 7 | module Schema 8 | class InterfaceType < Module 9 | include BaseType 10 | 11 | def initialize(type) 12 | unless type.kind.interface? 13 | raise "expected type to be an Interface, but was #{type.class}" 14 | end 15 | 16 | @type = type 17 | end 18 | 19 | def new(types) 20 | PossibleTypes.new(type, types) 21 | end 22 | 23 | def define_class(definition, ast_nodes) 24 | possible_type_names = definition.client.possible_types(type).map(&:graphql_name) 25 | possible_types = possible_type_names.map { |concrete_type_name| 26 | schema_module.get_class(concrete_type_name).define_class(definition, ast_nodes) 27 | } 28 | new(possible_types) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/list_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/error" 4 | require "graphql/client/list" 5 | require "graphql/client/schema/base_type" 6 | 7 | module GraphQL 8 | class Client 9 | module Schema 10 | class ListType 11 | include BaseType 12 | 13 | # Internal: Construct list wrapper from other BaseType. 14 | # 15 | # of_klass - BaseType instance 16 | def initialize(of_klass) 17 | unless of_klass.is_a?(BaseType) 18 | raise TypeError, "expected #{of_klass.inspect} to be a #{BaseType}" 19 | end 20 | 21 | @of_klass = of_klass 22 | end 23 | 24 | # Internal: Get wrapped klass. 25 | # 26 | # Returns BaseType instance. 27 | attr_reader :of_klass 28 | 29 | # Internal: Cast JSON value to wrapped value. 30 | # 31 | # values - JSON value 32 | # errors - Errors instance 33 | # 34 | # Returns List instance or nil. 35 | def cast(values, errors) 36 | case values 37 | when Array 38 | List.new(values.each_with_index.map { |e, idx| 39 | of_klass.cast(e, errors.filter_by_path(idx)) 40 | }, errors) 41 | when NilClass 42 | nil 43 | else 44 | raise InvariantError, "expected value to be a list, but was #{values.class}" 45 | end 46 | end 47 | 48 | # Internal: Get list wrapper of this type class. 49 | # 50 | # Returns ListType instance. 51 | def to_list_type 52 | self 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/non_null_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/error" 4 | require "graphql/client/schema/base_type" 5 | 6 | module GraphQL 7 | class Client 8 | module Schema 9 | class NonNullType 10 | include BaseType 11 | 12 | # Internal: Construct non-nullable wrapper from other BaseType. 13 | # 14 | # of_klass - BaseType instance 15 | def initialize(of_klass) 16 | unless of_klass.is_a?(BaseType) 17 | raise TypeError, "expected #{of_klass.inspect} to be a #{BaseType}" 18 | end 19 | 20 | @of_klass = of_klass 21 | end 22 | 23 | # Internal: Get wrapped klass. 24 | # 25 | # Returns BaseType instance. 26 | attr_reader :of_klass 27 | 28 | # Internal: Cast JSON value to wrapped value. 29 | # 30 | # value - JSON value 31 | # errors - Errors instance 32 | # 33 | # Returns BaseType instance. 34 | def cast(value, errors) 35 | case value 36 | when NilClass 37 | raise InvariantError, "expected value to be non-nullable, but was nil" 38 | else 39 | of_klass.cast(value, errors) 40 | end 41 | end 42 | 43 | # Internal: Get non-nullable wrapper of this type class. 44 | # 45 | # Returns NonNullType instance. 46 | def to_non_null_type 47 | self 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/object_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "active_support/inflector" 3 | require "graphql/client/error" 4 | require "graphql/client/errors" 5 | require "graphql/client/schema/base_type" 6 | 7 | module GraphQL 8 | class Client 9 | module Schema 10 | module ObjectType 11 | def self.new(type, fields = {}) 12 | Class.new(ObjectClass) do 13 | extend BaseType 14 | extend ObjectType 15 | 16 | define_singleton_method(:type) { type } 17 | define_singleton_method(:fields) { fields } 18 | 19 | const_set(:READERS, {}) 20 | const_set(:PREDICATES, {}) 21 | end 22 | end 23 | 24 | class WithDefinition 25 | include BaseType 26 | include ObjectType 27 | 28 | EMPTY_SET = Set.new.freeze 29 | 30 | attr_reader :klass, :defined_fields, :definition 31 | 32 | def type 33 | @klass.type 34 | end 35 | 36 | def fields 37 | @klass.fields 38 | end 39 | 40 | def spreads 41 | if defined?(@spreads) 42 | @spreads 43 | else 44 | EMPTY_SET 45 | end 46 | end 47 | 48 | def initialize(klass, defined_fields, definition, spreads) 49 | @klass = klass 50 | @defined_fields = defined_fields.map do |k, v| 51 | [-k.to_s, v] 52 | end.to_h 53 | @definition = definition 54 | @spreads = spreads unless spreads.empty? 55 | 56 | @defined_fields.keys.each do |attr| 57 | name = ActiveSupport::Inflector.underscore(attr) 58 | @klass::READERS[:"#{name}"] ||= attr 59 | @klass::PREDICATES[:"#{name}?"] ||= attr 60 | end 61 | end 62 | 63 | def new(data = {}, errors = Errors.new) 64 | @klass.new(data, errors, self) 65 | end 66 | end 67 | 68 | def define_class(definition, ast_nodes) 69 | # First, gather all the ast nodes representing a certain selection, by name. 70 | # We gather AST nodes into arrays so that multiple selections can be grouped, for example: 71 | # 72 | # { 73 | # f1 { a b } 74 | # f1 { b c } 75 | # } 76 | # 77 | # should be treated like `f1 { a b c }` 78 | field_nodes = {} 79 | ast_nodes.each do |ast_node| 80 | ast_node.selections.each do |selected_ast_node| 81 | gather_selections(field_nodes, definition, selected_ast_node) 82 | end 83 | end 84 | 85 | # After gathering all the nodes by name, prepare to create methods and classes for them. 86 | field_classes = {} 87 | field_nodes.each do |result_name, field_ast_nodes| 88 | # `result_name` might be an alias, so make sure to get the proper name 89 | field_name = field_ast_nodes.first.name 90 | field_definition = definition.client.schema.get_field(type.graphql_name, field_name) 91 | field_return_type = field_definition.type 92 | field_classes[result_name.to_sym] = schema_module.define_class(definition, field_ast_nodes, field_return_type) 93 | end 94 | 95 | spreads = definition.indexes[:spreads][ast_nodes.first] 96 | 97 | WithDefinition.new(self, field_classes, definition, spreads) 98 | end 99 | 100 | def define_field(name, type) 101 | name = name.to_s 102 | method_name = ActiveSupport::Inflector.underscore(name) 103 | 104 | define_method(method_name) do 105 | @casted_data.fetch(name) do 106 | @casted_data[name] = type.cast(@data[name], @errors.filter_by_path(name)) 107 | end 108 | end 109 | 110 | define_method("#{method_name}?") do 111 | @data[name] ? true : false 112 | end 113 | end 114 | 115 | def cast(value, errors) 116 | case value 117 | when Hash 118 | new(value, errors) 119 | when NilClass 120 | nil 121 | else 122 | raise InvariantError, "expected value to be a Hash, but was #{value.class}" 123 | end 124 | end 125 | 126 | private 127 | 128 | # Given an AST selection on this object, gather it into `fields` if it applies. 129 | # If it's a fragment, continue recursively checking the selections on the fragment. 130 | def gather_selections(fields, definition, selected_ast_node) 131 | case selected_ast_node 132 | when GraphQL::Language::Nodes::InlineFragment 133 | continue_selection = if selected_ast_node.type.nil? 134 | true 135 | else 136 | type_condition = definition.client.get_type(selected_ast_node.type.name) 137 | applicable_types = definition.client.possible_types(type_condition) 138 | # continue if this object type is one of the types matching the fragment condition 139 | applicable_types.include?(type) 140 | end 141 | 142 | if continue_selection 143 | selected_ast_node.selections.each do |next_selected_ast_node| 144 | gather_selections(fields, definition, next_selected_ast_node) 145 | end 146 | end 147 | when GraphQL::Language::Nodes::FragmentSpread 148 | fragment_definition = definition.document.definitions.find do |defn| 149 | defn.is_a?(GraphQL::Language::Nodes::FragmentDefinition) && defn.name == selected_ast_node.name 150 | end 151 | type_condition = definition.client.get_type(fragment_definition.type.name) 152 | applicable_types = definition.client.possible_types(type_condition) 153 | # continue if this object type is one of the types matching the fragment condition 154 | continue_selection = applicable_types.include?(type) 155 | 156 | if continue_selection 157 | fragment_definition.selections.each do |next_selected_ast_node| 158 | gather_selections(fields, definition, next_selected_ast_node) 159 | end 160 | end 161 | when GraphQL::Language::Nodes::Field 162 | operation_definition_for_field = definition.indexes[:definitions][selected_ast_node] 163 | # Ignore fields defined in other documents. 164 | if definition.source_document.definitions.include?(operation_definition_for_field) 165 | field_method_name = selected_ast_node.alias || selected_ast_node.name 166 | ast_nodes = fields[field_method_name] ||= [] 167 | ast_nodes << selected_ast_node 168 | end 169 | else 170 | raise "Unexpected selection node: #{selected_ast_node}" 171 | end 172 | end 173 | end 174 | 175 | class ObjectClass 176 | def initialize(data = {}, errors = Errors.new, definer = nil) 177 | @data = data 178 | @casted_data = {} 179 | @errors = errors 180 | 181 | # If we are not provided a definition, we can use this empty default 182 | definer ||= ObjectType::WithDefinition.new(self.class, {}, nil, []) 183 | 184 | @definer = definer 185 | @enforce_collocated_callers = source_definition && source_definition.client.enforce_collocated_callers 186 | end 187 | 188 | # Public: Returns the raw response data 189 | # 190 | # Returns Hash 191 | def to_h 192 | @data 193 | end 194 | alias :to_hash :to_h 195 | 196 | def _definer 197 | @definer 198 | end 199 | 200 | def _spreads 201 | @definer.spreads 202 | end 203 | 204 | def source_definition 205 | @definer.definition 206 | end 207 | 208 | def respond_to_missing?(name, priv) 209 | if (attr = self.class::READERS[name]) || (attr = self.class::PREDICATES[name]) 210 | @definer.defined_fields.key?(attr) || super 211 | else 212 | super 213 | end 214 | end 215 | 216 | # Public: Return errors associated with data. 217 | # 218 | # It's possible to define "errors" as a field. Ideally this shouldn't 219 | # happen, but if it does we should prefer the field rather than the 220 | # builtin error type. 221 | # 222 | # Returns Errors collection. 223 | def errors 224 | if type = @definer.defined_fields["errors"] 225 | read_attribute("errors", type) 226 | else 227 | @errors 228 | end 229 | end 230 | 231 | def method_missing(name, *args) 232 | if (attr = self.class::READERS[name]) && (type = @definer.defined_fields[attr]) 233 | if @enforce_collocated_callers 234 | verify_collocated_path do 235 | read_attribute(attr, type) 236 | end 237 | else 238 | read_attribute(attr, type) 239 | end 240 | elsif (attr = self.class::PREDICATES[name]) && @definer.defined_fields[attr] 241 | has_attribute?(attr) 242 | else 243 | begin 244 | super 245 | rescue NoMethodError => e 246 | type = self.class.type 247 | 248 | if ActiveSupport::Inflector.underscore(e.name.to_s) != e.name.to_s 249 | raise e 250 | end 251 | 252 | all_fields = type.respond_to?(:all_fields) ? type.all_fields : type.fields.values 253 | field = all_fields.find do |f| 254 | f.name == e.name.to_s || ActiveSupport::Inflector.underscore(f.name) == e.name.to_s 255 | end 256 | 257 | unless field 258 | raise UnimplementedFieldError, "undefined field `#{e.name}' on #{type.graphql_name} type. https://github.com/github-community-projects/graphql-client/blob/master/guides/unimplemented-field-error.md" 259 | end 260 | 261 | if @data.key?(field.name) 262 | raise ImplicitlyFetchedFieldError, "implicitly fetched field `#{field.name}' on #{type} type. https://github.com/github-community-projects/graphql-client/blob/master/guides/implicitly-fetched-field-error.md" 263 | else 264 | raise UnfetchedFieldError, "unfetched field `#{field.name}' on #{type} type. https://github.com/github-community-projects/graphql-client/blob/master/guides/unfetched-field-error.md" 265 | end 266 | end 267 | end 268 | end 269 | 270 | def inspect 271 | parent = self.class 272 | until parent.superclass == ObjectClass 273 | parent = parent.superclass 274 | end 275 | 276 | ivars = @data.map { |key, value| 277 | if value.is_a?(Hash) || value.is_a?(Array) 278 | "#{key}=..." 279 | else 280 | "#{key}=#{value.inspect}" 281 | end 282 | } 283 | 284 | buf = "#<#{parent.name}".dup 285 | buf << " " << ivars.join(" ") if ivars.any? 286 | buf << ">" 287 | buf 288 | end 289 | 290 | private 291 | 292 | def verify_collocated_path 293 | location = caller_locations(2, 1)[0] 294 | 295 | CollocatedEnforcement.verify_collocated_path(location, source_definition.source_location[0]) do 296 | yield 297 | end 298 | end 299 | 300 | def read_attribute(attr, type) 301 | @casted_data.fetch(attr) do 302 | @casted_data[attr] = type.cast(@data[attr], @errors.filter_by_path(attr)) 303 | end 304 | end 305 | 306 | def has_attribute?(attr) 307 | !!@data[attr] 308 | end 309 | end 310 | end 311 | end 312 | end 313 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/possible_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/error" 4 | require "graphql/client/schema/base_type" 5 | require "graphql/client/schema/object_type" 6 | 7 | module GraphQL 8 | class Client 9 | module Schema 10 | class PossibleTypes 11 | include BaseType 12 | 13 | def initialize(type, types) 14 | @type = type 15 | 16 | unless types.is_a?(Enumerable) 17 | raise TypeError, "expected types to be Enumerable, but was #{types.class}" 18 | end 19 | 20 | @possible_types = {} 21 | types.each do |klass| 22 | unless klass.is_a?(ObjectType) 23 | raise TypeError, "expected type to be #{ObjectType}, but was #{type.class}" 24 | end 25 | @possible_types[klass.type.graphql_name] = klass 26 | end 27 | end 28 | 29 | attr_reader :possible_types 30 | 31 | # Internal: Cast JSON value to wrapped value. 32 | # 33 | # value - JSON value 34 | # errors - Errors instance 35 | # 36 | # Returns BaseType instance. 37 | def cast(value, errors) 38 | case value 39 | when Hash 40 | typename = value["__typename"] 41 | if type = possible_types[typename] 42 | type.cast(value, errors) 43 | else 44 | raise InvariantError, "expected value to be one of (#{possible_types.keys.join(", ")}), but was #{typename.inspect}" 45 | end 46 | when NilClass 47 | nil 48 | else 49 | raise InvariantError, "expected value to be a Hash, but was #{value.class}" 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/scalar_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/schema/base_type" 4 | 5 | module GraphQL 6 | class Client 7 | module Schema 8 | class ScalarType 9 | include BaseType 10 | 11 | # Internal: Construct type wrapper from another GraphQL::BaseType. 12 | # 13 | # type - GraphQL::BaseType instance 14 | def initialize(type) 15 | unless type.kind.scalar? 16 | raise "expected type to be a Scalar, but was #{type.class}" 17 | end 18 | 19 | @type = type 20 | end 21 | 22 | def define_class(definition, ast_nodes) 23 | self 24 | end 25 | 26 | # Internal: Cast raw JSON value to Ruby scalar object. 27 | # 28 | # value - JSON value 29 | # errors - Errors instance 30 | # 31 | # Returns casted Object. 32 | def cast(value, _errors = nil) 33 | case value 34 | when NilClass 35 | nil 36 | else 37 | if type.respond_to?(:coerce_isolated_input) 38 | type.coerce_isolated_input(value) 39 | else 40 | type.coerce_input(value) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/skip_directive.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/schema/base_type" 4 | 5 | module GraphQL 6 | class Client 7 | module Schema 8 | class SkipDirective 9 | include BaseType 10 | 11 | # Internal: Construct list wrapper from other BaseType. 12 | # 13 | # of_klass - BaseType instance 14 | def initialize(of_klass) 15 | unless of_klass.is_a?(BaseType) 16 | raise TypeError, "expected #{of_klass.inspect} to be a #{BaseType}" 17 | end 18 | 19 | @of_klass = of_klass 20 | end 21 | 22 | # Internal: Get wrapped klass. 23 | # 24 | # Returns BaseType instance. 25 | attr_reader :of_klass 26 | 27 | # Internal: Cast JSON value to wrapped value. 28 | # 29 | # values - JSON value 30 | # errors - Errors instance 31 | # 32 | # Returns List instance or nil. 33 | def cast(value, errors) 34 | case value 35 | when NilClass 36 | nil 37 | else 38 | of_klass.cast(value, errors) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/graphql/client/schema/union_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql/client/schema/possible_types" 4 | 5 | module GraphQL 6 | class Client 7 | module Schema 8 | class UnionType < Module 9 | include BaseType 10 | 11 | def initialize(type) 12 | unless type.kind.union? 13 | raise "expected type to be a Union, but was #{type.class}" 14 | end 15 | 16 | @type = type 17 | end 18 | 19 | def new(types) 20 | PossibleTypes.new(type, types) 21 | end 22 | 23 | def define_class(definition, ast_nodes) 24 | possible_type_names = definition.client.possible_types(type).map(&:graphql_name) 25 | possible_types = possible_type_names.map { |concrete_type_name| 26 | schema_module.get_class(concrete_type_name).define_class(definition, ast_nodes) 27 | } 28 | new(possible_types) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/graphql/client/type_stack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module GraphQL 3 | class Client 4 | module TypeStack 5 | # @return [GraphQL::Schema] the schema whose types are present in this document 6 | attr_reader :schema 7 | 8 | # When it enters an object (starting with query or mutation root), it's pushed on this stack. 9 | # When it exits, it's popped off. 10 | # @return [Array] 11 | attr_reader :object_types 12 | 13 | # When it enters a field, it's pushed on this stack (useful for nested fields, args). 14 | # When it exits, it's popped off. 15 | # @return [Array] fields which have been entered 16 | attr_reader :field_definitions 17 | 18 | # Directives are pushed on, then popped off while traversing the tree 19 | # @return [Array] directives which have been entered 20 | attr_reader :directive_definitions 21 | 22 | # @return [Array] arguments which have been entered 23 | attr_reader :argument_definitions 24 | 25 | # @return [Array] fields which have been entered (by their AST name) 26 | attr_reader :path 27 | 28 | # @param schema [GraphQL::Schema] the schema whose types to use when climbing this document 29 | # @param visitor [GraphQL::Language::Visitor] a visitor to follow & watch the types 30 | def initialize(document, schema:, **rest) 31 | @schema = schema 32 | @object_types = [] 33 | @field_definitions = [] 34 | @directive_definitions = [] 35 | @argument_definitions = [] 36 | @path = [] 37 | super(document, **rest) 38 | end 39 | 40 | def on_directive(node, parent) 41 | directive_defn = @schema.directives[node.name] 42 | @directive_definitions.push(directive_defn) 43 | super(node, parent) 44 | ensure 45 | @directive_definitions.pop 46 | end 47 | 48 | def on_field(node, parent) 49 | parent_type = @object_types.last 50 | parent_type = parent_type.unwrap 51 | 52 | field_definition = @schema.get_field(parent_type, node.name) 53 | @field_definitions.push(field_definition) 54 | if !field_definition.nil? 55 | next_object_type = field_definition.type 56 | @object_types.push(next_object_type) 57 | else 58 | @object_types.push(nil) 59 | end 60 | @path.push(node.alias || node.name) 61 | super(node, parent) 62 | ensure 63 | @field_definitions.pop 64 | @object_types.pop 65 | @path.pop 66 | end 67 | 68 | def on_argument(node, parent) 69 | if @argument_definitions.last 70 | arg_type = @argument_definitions.last.type.unwrap 71 | if arg_type.kind.input_object? 72 | argument_defn = arg_type.arguments[node.name] 73 | else 74 | argument_defn = nil 75 | end 76 | elsif @directive_definitions.last 77 | argument_defn = @directive_definitions.last.arguments[node.name] 78 | elsif @field_definitions.last 79 | argument_defn = @field_definitions.last.arguments[node.name] 80 | else 81 | argument_defn = nil 82 | end 83 | @argument_definitions.push(argument_defn) 84 | @path.push(node.name) 85 | super(node, parent) 86 | ensure 87 | @argument_definitions.pop 88 | @path.pop 89 | end 90 | 91 | def on_operation_definition(node, parent) 92 | # eg, QueryType, MutationType 93 | object_type = @schema.root_type_for_operation(node.operation_type) 94 | @object_types.push(object_type) 95 | @path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") 96 | super(node, parent) 97 | ensure 98 | @object_types.pop 99 | @path.pop 100 | end 101 | 102 | def on_inline_fragment(node, parent) 103 | object_type = if node.type 104 | @schema.get_type(node.type.name) 105 | else 106 | @object_types.last 107 | end 108 | if !object_type.nil? 109 | object_type = object_type.unwrap 110 | end 111 | @object_types.push(object_type) 112 | @path.push("...#{node.type ? " on #{node.type.to_query_string}" : ""}") 113 | super(node, parent) 114 | ensure 115 | @object_types.pop 116 | @path.pop 117 | end 118 | 119 | def on_fragment_definition(node, parent) 120 | object_type = if node.type 121 | @schema.get_type(node.type.name) 122 | else 123 | @object_types.last 124 | end 125 | if !object_type.nil? 126 | object_type = object_type.unwrap 127 | end 128 | @object_types.push(object_type) 129 | @path.push("fragment #{node.name}") 130 | super(node, parent) 131 | ensure 132 | @object_types.pop 133 | @path.pop 134 | end 135 | 136 | def on_fragment_spread(node, parent) 137 | @path.push("... #{node.name}") 138 | super(node, parent) 139 | ensure 140 | @path.pop 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/graphql/client/view_module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "active_support/dependencies" 3 | require "active_support/inflector" 4 | require "graphql/client/erubis_enhancer" 5 | 6 | module GraphQL 7 | class Client 8 | # Allows a magic namespace to map to app/views/**/*.erb files to retrieve 9 | # statically defined GraphQL definitions. 10 | # 11 | # # app/views/users/show.html.erb 12 | # <%grapql 13 | # fragment UserFragment on User { } 14 | # %> 15 | # 16 | # # Loads graphql section from app/views/users/show.html.erb 17 | # Views::Users::Show::UserFragment 18 | # 19 | module ViewModule 20 | attr_accessor :client 21 | 22 | # Public: Extract GraphQL section from ERB template. 23 | # 24 | # src - String ERB text 25 | # 26 | # Returns String GraphQL query and line number or nil or no section was 27 | # defined. 28 | def self.extract_graphql_section(src) 29 | query_string = src.scan(/<%graphql([^%]+)%>/).flatten.first 30 | return nil unless query_string 31 | [query_string, Regexp.last_match.pre_match.count("\n") + 1] 32 | end 33 | 34 | # Public: Eager load module and all subdependencies. 35 | # 36 | # Use in production when cache_classes is true. 37 | # 38 | # Traverses all app/views/**/*.erb and loads all static constants defined in 39 | # ERB files. 40 | # 41 | # Examples 42 | # 43 | # Views.eager_load! 44 | # 45 | # Returns nothing. 46 | def eager_load! 47 | return unless File.directory?(load_path) 48 | 49 | Dir.entries(load_path).sort.each do |entry| 50 | next if entry == "." || entry == ".." 51 | name = entry.sub(/(\.\w+)+$/, "").camelize.to_sym 52 | if ViewModule.valid_constant_name?(name) 53 | mod = const_defined?(name, false) ? const_get(name) : load_and_set_module(name) 54 | mod.eager_load! if mod 55 | end 56 | end 57 | 58 | nil 59 | end 60 | 61 | # Internal: Check if name is a valid Ruby constant identifier. 62 | # 63 | # name - String or Symbol constant name 64 | # 65 | # Examples 66 | # 67 | # valid_constant_name?("Foo") #=> true 68 | # valid_constant_name?("404") #=> false 69 | # 70 | # Returns true if name is a valid constant, otherwise false if name would 71 | # result in a "NameError: wrong constant name". 72 | def self.valid_constant_name?(name) 73 | name.to_s =~ /^[A-Z][a-zA-Z0-9_]*$/ 74 | end 75 | 76 | # Public: Directory to retrieve nested GraphQL definitions from. 77 | # 78 | # Returns absolute String path under app/views. 79 | attr_accessor :load_path 80 | alias_method :path=, :load_path= 81 | alias_method :path, :load_path 82 | 83 | # Public: if this module was defined by a view 84 | # 85 | # Returns absolute String path under app/views. 86 | attr_accessor :source_path 87 | 88 | # Internal: Initialize new module for constant name and load ERB statics. 89 | # 90 | # name - String or Symbol constant name. 91 | # 92 | # Examples 93 | # 94 | # Views::Users.load_module(:Profile) 95 | # Views::Users::Profile.load_module(:Show) 96 | # 97 | # Returns new Module implementing Loadable concern. 98 | def load_module(name) 99 | pathname = ActiveSupport::Inflector.underscore(name.to_s) 100 | path = Dir[File.join(load_path, "{#{pathname},_#{pathname}}{.*}")].sort.map { |fn| File.expand_path(fn) }.first 101 | 102 | return if !path || File.extname(path) != ".erb" 103 | 104 | contents = File.read(path) 105 | query, lineno = ViewModule.extract_graphql_section(contents) 106 | return unless query 107 | 108 | mod = client.parse(query, path, lineno) 109 | mod.extend(ViewModule) 110 | mod.load_path = File.join(load_path, pathname) 111 | mod.source_path = path 112 | mod.client = client 113 | mod 114 | end 115 | 116 | def placeholder_module(name) 117 | dirname = File.join(load_path, ActiveSupport::Inflector.underscore(name.to_s)) 118 | return nil unless Dir.exist?(dirname) 119 | 120 | Module.new.tap do |mod| 121 | mod.extend(ViewModule) 122 | mod.load_path = dirname 123 | mod.client = client 124 | end 125 | end 126 | 127 | def load_and_set_module(name) 128 | placeholder = placeholder_module(name) 129 | const_set(name, placeholder) if placeholder 130 | 131 | mod = load_module(name) 132 | return placeholder unless mod 133 | 134 | remove_const(name) if placeholder 135 | const_set(name, mod) 136 | mod.unloadable if mod.respond_to?(:unloadable) 137 | mod 138 | end 139 | 140 | # Public: Implement constant missing hook to autoload View ERB statics. 141 | # 142 | # name - String or Symbol constant name 143 | # 144 | # Returns module or raises NameError if missing. 145 | def const_missing(name) 146 | load_and_set_module(name) || super 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/rubocop/cop/graphql/heredoc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rubocop" 3 | 4 | module RuboCop 5 | module Cop 6 | module GraphQL 7 | # Public: Cop for enforcing non-interpolated GRAPHQL heredocs. 8 | class Heredoc < Base 9 | def on_dstr(node) 10 | check_str(node) 11 | end 12 | 13 | def on_str(node) 14 | check_str(node) 15 | end 16 | 17 | def check_str(node) 18 | return unless node.location.is_a?(Parser::Source::Map::Heredoc) 19 | return unless node.location.expression.source =~ /^<<(-|~)?GRAPHQL/ 20 | 21 | node.each_child_node(:begin) do |begin_node| 22 | add_offense(begin_node, message: "Do not interpolate variables into GraphQL queries, " \ 23 | "used variables instead.") 24 | end 25 | 26 | add_offense(node, message: "GraphQL heredocs should be quoted. <<-'GRAPHQL'") 27 | end 28 | 29 | def autocorrect(node) 30 | ->(corrector) do 31 | corrector.replace(node.location.expression, "<<-'GRAPHQL'") 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rubocop/cop/graphql/overfetch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "active_support/inflector" 3 | require "graphql" 4 | require "graphql/client/view_module" 5 | require "rubocop" 6 | 7 | module RuboCop 8 | module Cop 9 | module GraphQL 10 | # Public: Rubocop for catching overfetched fields in ERB templates. 11 | class Overfetch < Base 12 | if defined?(RangeHelp) 13 | # rubocop 0.53 moved the #source_range method into this module 14 | include RangeHelp 15 | end 16 | 17 | def_node_search :send_methods, "({send csend block_pass} ...)" 18 | 19 | def investigate(processed_source) 20 | erb = File.read(processed_source.buffer.name) 21 | query, = ::GraphQL::Client::ViewModule.extract_graphql_section(erb) 22 | return unless query 23 | 24 | # TODO: Use GraphQL client parser 25 | document = ::GraphQL.parse(query.gsub(/::/, "__")) 26 | visitor = OverfetchVisitor.new(document) do |line_num| 27 | # `source_range` is private to this object, 28 | # so yield back out to it to get this info: 29 | source_range(processed_source.buffer, line_num, 0) 30 | end 31 | visitor.visit 32 | 33 | send_methods(processed_source.ast).each do |node| 34 | method_names = method_names_for(*node) 35 | 36 | method_names.each do |method_name| 37 | visitor.aliases.fetch(method_name, []).each do |field_name| 38 | visitor.fields[field_name] += 1 39 | end 40 | end 41 | end 42 | 43 | visitor.fields.each do |field, count| 44 | next if count > 0 45 | add_offense(visitor.ranges[field], message: "GraphQL field '#{field}' query but was not used in template.") 46 | end 47 | end 48 | 49 | class OverfetchVisitor < ::GraphQL::Language::Visitor 50 | def initialize(doc, &range_for_line) 51 | super(doc) 52 | @range_for_line = range_for_line 53 | @fields = {} 54 | @aliases = {} 55 | @ranges = {} 56 | end 57 | 58 | attr_reader :fields, :aliases, :ranges 59 | 60 | def on_field(node, parent) 61 | name = node.alias || node.name 62 | fields[name] ||= 0 63 | field_aliases(name).each { |n| (aliases[n] ||= []) << name } 64 | ranges[name] ||= @range_for_line.call(node.line) 65 | super 66 | end 67 | 68 | private 69 | 70 | def field_aliases(name) 71 | names = Set.new 72 | 73 | names << name 74 | names << "#{name}?" 75 | 76 | names << underscore_name = ActiveSupport::Inflector.underscore(name) 77 | names << "#{underscore_name}?" 78 | 79 | names 80 | end 81 | end 82 | 83 | 84 | def method_names_for(*node) 85 | receiver, method_name, *_args = node 86 | method_names = [] 87 | 88 | method_names << method_name if method_name 89 | 90 | # add field accesses like `nodes.map(&:field)` 91 | method_names.concat(receiver.children) if receiver && receiver.sym_type? 92 | 93 | method_names.map!(&:to_s) 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/foo_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FooHelper 3 | def format_person_info(person) 4 | "#{person.name} works at #{person.company}" 5 | end 6 | 7 | def format_person_info_via_send(person) 8 | "#{person.public_send(:name)} works at #{person.public_send(:company)}" 9 | end 10 | 11 | def person_employed?(person) 12 | person.company? 13 | end 14 | 15 | def format_person_name(person) 16 | "#{person.first_name} #{person.last_name}" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/test_client_create_operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "minitest/autorun" 5 | 6 | class TestClientCreateOperation < Minitest::Test 7 | class UserType < GraphQL::Schema::Object 8 | field :id, ID, null: false 9 | end 10 | 11 | class QueryType < GraphQL::Schema::Object 12 | field :version, Int, null: false 13 | field :user, UserType, null: true do 14 | argument :name, String, required: true 15 | end 16 | field :users, [UserType], null: false do 17 | argument :name, String, required: false 18 | argument :names, [String], required: false 19 | end 20 | end 21 | 22 | class CreateUserInput < GraphQL::Schema::InputObject 23 | argument :name, String, required: true 24 | end 25 | 26 | class MutationType < GraphQL::Schema::Object 27 | field :create_user, UserType, null: true do 28 | argument :input, CreateUserInput, required: true 29 | end 30 | end 31 | 32 | class Schema < GraphQL::Schema 33 | query(QueryType) 34 | mutation(MutationType) 35 | end 36 | 37 | module Temp 38 | end 39 | 40 | def setup 41 | @client = GraphQL::Client.new(schema: Schema, execute: Schema) 42 | end 43 | 44 | def teardown 45 | Temp.constants.each do |sym| 46 | Temp.send(:remove_const, sym) 47 | end 48 | end 49 | 50 | def test_query_from_fragment_with_on_wrong_query_type 51 | Temp.const_set :Fragment, @client.parse(<<-'GRAPHQL') 52 | fragment on User { 53 | id 54 | } 55 | GRAPHQL 56 | 57 | assert_raises GraphQL::Client::Error, "Fragment must be defined on Query, Mutation" do 58 | @client.create_operation(Temp::Fragment) 59 | end 60 | end 61 | 62 | def test_query_from_fragment_with_no_variables 63 | Temp.const_set :Fragment, @client.parse(<<-'GRAPHQL') 64 | fragment on Query { 65 | version 66 | } 67 | GRAPHQL 68 | 69 | Temp.const_set :Query, @client.create_operation(Temp::Fragment) 70 | 71 | query_string = <<-'GRAPHQL'.gsub(/^ /, "").chomp 72 | query TestClientCreateOperation__Temp__Query { 73 | ...TestClientCreateOperation__Temp__Fragment 74 | } 75 | 76 | fragment TestClientCreateOperation__Temp__Fragment on Query { 77 | version 78 | } 79 | GRAPHQL 80 | assert_equal(query_string, Temp::Query.document.to_query_string) 81 | end 82 | 83 | def test_query_from_fragment_with_one_non_nullable_scalar_variables 84 | Temp.const_set :Fragment, @client.parse(<<-'GRAPHQL') 85 | fragment on Query { 86 | user(name: $name) { 87 | id 88 | } 89 | } 90 | GRAPHQL 91 | 92 | Temp.const_set :Query, @client.create_operation(Temp::Fragment) 93 | 94 | query_string = <<-'GRAPHQL'.gsub(/^ /, "").chomp 95 | query TestClientCreateOperation__Temp__Query($name: String!) { 96 | ...TestClientCreateOperation__Temp__Fragment 97 | } 98 | 99 | fragment TestClientCreateOperation__Temp__Fragment on Query { 100 | user(name: $name) { 101 | id 102 | } 103 | } 104 | GRAPHQL 105 | assert_equal(query_string, Temp::Query.document.to_query_string) 106 | end 107 | 108 | def test_query_from_fragment_with_one_nullable_scalar_variables 109 | Temp.const_set :Fragment, @client.parse(<<-'GRAPHQL') 110 | fragment on Query { 111 | users(name: $name) { 112 | id 113 | } 114 | } 115 | GRAPHQL 116 | 117 | Temp.const_set :Query, @client.create_operation(Temp::Fragment) 118 | 119 | query_string = <<-'GRAPHQL'.gsub(/^ /, "").chomp 120 | query TestClientCreateOperation__Temp__Query($name: String) { 121 | ...TestClientCreateOperation__Temp__Fragment 122 | } 123 | 124 | fragment TestClientCreateOperation__Temp__Fragment on Query { 125 | users(name: $name) { 126 | id 127 | } 128 | } 129 | GRAPHQL 130 | assert_equal(query_string, Temp::Query.document.to_query_string) 131 | end 132 | 133 | def test_query_from_fragment_with_list_of_scalar_variables 134 | Temp.const_set :Fragment, @client.parse(<<-'GRAPHQL') 135 | fragment on Query { 136 | users(names: $names) { 137 | id 138 | } 139 | } 140 | GRAPHQL 141 | 142 | Temp.const_set :Query, @client.create_operation(Temp::Fragment) 143 | 144 | query_string = <<-'GRAPHQL'.gsub(/^ /, "").chomp 145 | query TestClientCreateOperation__Temp__Query($names: [String!]) { 146 | ...TestClientCreateOperation__Temp__Fragment 147 | } 148 | 149 | fragment TestClientCreateOperation__Temp__Fragment on Query { 150 | users(names: $names) { 151 | id 152 | } 153 | } 154 | GRAPHQL 155 | assert_equal(query_string, Temp::Query.document.to_query_string) 156 | end 157 | 158 | def test_mutation_from_fragment_with_input_type_variable 159 | Temp.const_set :Fragment, @client.parse(<<-'GRAPHQL') 160 | fragment on Mutation { 161 | createUser(input: $input) { 162 | id 163 | } 164 | } 165 | GRAPHQL 166 | 167 | Temp.const_set :Query, @client.create_operation(Temp::Fragment) 168 | 169 | query_string = <<-'GRAPHQL'.gsub(/^ /, "").chomp 170 | mutation TestClientCreateOperation__Temp__Query($input: CreateUserInput!) { 171 | ...TestClientCreateOperation__Temp__Fragment 172 | } 173 | 174 | fragment TestClientCreateOperation__Temp__Fragment on Mutation { 175 | createUser(input: $input) { 176 | id 177 | } 178 | } 179 | GRAPHQL 180 | assert_equal(query_string, Temp::Query.document.to_query_string) 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/test_client_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "minitest/autorun" 5 | 6 | class TestClientErrors < Minitest::Test 7 | class FooType < GraphQL::Schema::Object 8 | field :nullable_error, String, null: true 9 | def nullable_error 10 | raise GraphQL::ExecutionError, "b00m" 11 | end 12 | 13 | field :nonnullable_error, String, null: false 14 | def nonnullable_error 15 | raise GraphQL::ExecutionError, "b00m" 16 | end 17 | end 18 | 19 | class QueryType < GraphQL::Schema::Object 20 | field :version, Int, null: false 21 | def version 22 | 1 23 | end 24 | 25 | field :node, FooType, null: true 26 | def node 27 | GraphQL::ExecutionError.new("missing node") 28 | end 29 | 30 | field :nodes, [FooType, null: true], null: false 31 | def nodes 32 | [GraphQL::ExecutionError.new("missing node"), {}] 33 | end 34 | 35 | field :nullable_error, String, null: true 36 | def nullable_error 37 | raise GraphQL::ExecutionError, "b00m" 38 | end 39 | 40 | field :nonnullable_error, String, null: false 41 | def nonnullable_error 42 | raise GraphQL::ExecutionError, "b00m" 43 | end 44 | 45 | field :foo, FooType, null: false 46 | def foo 47 | {} 48 | end 49 | 50 | field :foos, [FooType], null: true 51 | def foos 52 | [{}, {}] 53 | end 54 | end 55 | 56 | class Schema < GraphQL::Schema 57 | query(QueryType) 58 | end 59 | 60 | module Temp 61 | end 62 | 63 | def setup 64 | @client = GraphQL::Client.new(schema: Schema, execute: Schema) 65 | end 66 | 67 | def teardown 68 | Temp.constants.each do |sym| 69 | Temp.send(:remove_const, sym) 70 | end 71 | end 72 | 73 | def test_normalize_error_path 74 | actual = { 75 | "data" => nil, 76 | "errors" => [ 77 | { 78 | "message" => "error" 79 | } 80 | ] 81 | } 82 | GraphQL::Client::Errors.normalize_error_paths(actual["data"], actual["errors"]) 83 | expected = { 84 | "data" => nil, 85 | "errors" => [ 86 | { 87 | "message" => "error", 88 | "normalizedPath" => %w(data) 89 | } 90 | ] 91 | } 92 | assert_equal expected, actual 93 | 94 | actual = { 95 | "data" => { 96 | "node" => nil 97 | }, 98 | "errors" => [ 99 | { 100 | "message" => "error", 101 | "path" => %w(node) 102 | } 103 | ] 104 | } 105 | GraphQL::Client::Errors.normalize_error_paths(actual["data"], actual["errors"]) 106 | expected = { 107 | "data" => { 108 | "node" => nil 109 | }, 110 | "errors" => [ 111 | { 112 | "message" => "error", 113 | "path" => %w(node), 114 | "normalizedPath" => %w(data node) 115 | } 116 | ] 117 | } 118 | assert_equal expected, actual 119 | 120 | actual = { 121 | "data" => nil, 122 | "errors" => [ 123 | { 124 | "message" => "error", 125 | "path" => %w(node projects owner) 126 | } 127 | ] 128 | } 129 | GraphQL::Client::Errors.normalize_error_paths(actual["data"], actual["errors"]) 130 | expected = { 131 | "data" => nil, 132 | "errors" => [ 133 | { 134 | "message" => "error", 135 | "path" => %w(node projects owner), 136 | "normalizedPath" => %w(data) 137 | } 138 | ] 139 | } 140 | assert_equal expected, actual 141 | 142 | actual = { 143 | "data" => { 144 | "node" => nil 145 | }, 146 | "errors" => [ 147 | { 148 | "message" => "error", 149 | "path" => %w(node projects owner) 150 | } 151 | ] 152 | } 153 | GraphQL::Client::Errors.normalize_error_paths(actual["data"], actual["errors"]) 154 | expected = { 155 | "data" => { 156 | "node" => nil 157 | }, 158 | "errors" => [ 159 | { 160 | "message" => "error", 161 | "path" => %w(node projects owner), 162 | "normalizedPath" => %w(data node) 163 | } 164 | ] 165 | } 166 | assert_equal expected, actual 167 | 168 | actual = { 169 | "data" => nil, 170 | "errors" => [ 171 | { 172 | "message" => "error", 173 | "path" => nil 174 | } 175 | ] 176 | } 177 | GraphQL::Client::Errors.normalize_error_paths(actual["data"], actual["errors"]) 178 | expected = { 179 | "data" => nil, 180 | "errors" => [ 181 | { 182 | "message" => "error", 183 | "path" => nil, 184 | "normalizedPath" => %w(data), 185 | }, 186 | ], 187 | } 188 | assert_equal expected, actual 189 | end 190 | 191 | def test_filter_nested_errors_by_path 192 | raw_errors = [ 193 | { 194 | "message" => "1", 195 | "normalizedPath" => %w(node id) 196 | }, 197 | { 198 | "message" => "2", 199 | "normalizedPath" => %w(node owner name) 200 | }, 201 | { 202 | "message" => "3", 203 | "normalizedPath" => ["node", "repositories", 0, "name"] 204 | }, 205 | { 206 | "message" => "4", 207 | "normalizedPath" => ["version"] 208 | } 209 | ] 210 | 211 | errors = GraphQL::Client::Errors.new(raw_errors, [], true) 212 | assert_equal 4, errors.count 213 | assert_equal({ "node" => %w(1 2 3), "version" => ["4"] }, errors.messages.to_h) 214 | 215 | errors = GraphQL::Client::Errors.new(raw_errors, ["node"], true) 216 | assert_equal 3, errors.count 217 | assert_equal({ "id" => ["1"], "owner" => ["2"], "repositories" => ["3"] }, errors.messages.to_h) 218 | 219 | errors = GraphQL::Client::Errors.new(raw_errors, ["version"], true) 220 | assert_empty errors 221 | end 222 | 223 | def test_filter_direct_errors_by_path 224 | raw_errors = [ 225 | { 226 | "message" => "1", 227 | "normalizedPath" => %w(node id) 228 | }, 229 | { 230 | "message" => "2", 231 | "normalizedPath" => %w(node owner name) 232 | }, 233 | { 234 | "message" => "3", 235 | "normalizedPath" => ["node", "repositories", 0, "name"] 236 | }, 237 | { 238 | "message" => "4", 239 | "normalizedPath" => ["version"] 240 | } 241 | ] 242 | 243 | errors = GraphQL::Client::Errors.new(raw_errors, [], false) 244 | assert_equal 1, errors.count 245 | assert_equal({ "version" => ["4"] }, errors.messages.to_h) 246 | 247 | errors = GraphQL::Client::Errors.new(raw_errors, ["node"], false) 248 | assert_equal 1, errors.count 249 | assert_equal({ "id" => ["1"] }, errors.messages.to_h) 250 | 251 | errors = GraphQL::Client::Errors.new(raw_errors, %w(node owner), false) 252 | assert_equal 1, errors.count 253 | assert_equal({ "name" => ["2"] }, errors.messages.to_h) 254 | 255 | errors = GraphQL::Client::Errors.new(raw_errors, ["node", "repositories", 0], false) 256 | assert_equal 1, errors.count 257 | assert_equal({ "name" => ["3"] }, errors.messages.to_h) 258 | 259 | errors = GraphQL::Client::Errors.new(raw_errors, ["version"], false) 260 | assert_empty errors 261 | end 262 | 263 | def test_errors_collection 264 | Temp.const_set :Query, @client.parse("{ nullableError }") 265 | assert response = @client.query(Temp::Query) 266 | 267 | assert_nil response.data.nullable_error 268 | 269 | assert_equal false, response.data.errors.empty? 270 | assert_equal false, response.data.errors.blank? 271 | 272 | assert_equal 1, response.data.errors.size 273 | assert_equal 1, response.data.errors.count 274 | 275 | assert_equal true, response.data.errors.include?(:nullableError) 276 | assert_equal true, response.data.errors.include?("nullableError") 277 | assert_equal true, response.data.errors.include?(:nullable_error) 278 | assert_equal true, response.data.errors[:nullableError].any? 279 | assert_equal true, response.data.errors["nullableError"].any? 280 | assert_equal true, response.data.errors[:nullable_error].any? 281 | 282 | assert_equal false, response.data.errors.include?(:missingError) 283 | assert_equal false, response.data.errors.include?("missingError") 284 | assert_equal false, response.data.errors.include?(:missing_error) 285 | assert_equal false, response.data.errors[:missingError].any? 286 | assert_equal false, response.data.errors["missingError"].any? 287 | assert_equal false, response.data.errors[:missing_error].any? 288 | 289 | assert_equal "b00m", response.data.errors[:nullableError][0] 290 | assert_equal "b00m", response.data.errors[:nullable_error][0] 291 | 292 | assert_equal "b00m", response.data.errors.messages["nullableError"][0] 293 | 294 | detail = { 295 | "message" => "b00m", 296 | "locations" => [{ "line" => 1, "column" => 3 }], 297 | "path" => %w(nullableError), 298 | "normalizedPath" => %w(data nullableError) 299 | } 300 | assert_equal(detail, response.data.errors.details["nullableError"][0]) 301 | 302 | assert_equal [%w(nullableError b00m)], response.data.errors.each.to_a 303 | assert_equal ["nullableError"], response.data.errors.keys 304 | assert_equal [["b00m"]], response.data.errors.values 305 | 306 | assert_equal({ 307 | "data" => { 308 | "nullableError" => nil 309 | }, 310 | "errors" => [ 311 | { 312 | "message" => "b00m", 313 | "locations" => [{ "line" => 1, "column" => 3 }], 314 | "path" => ["nullableError"] 315 | } 316 | ] 317 | }, response.to_h) 318 | end 319 | 320 | def test_nested_errors 321 | Temp.const_set :Query, @client.parse("{ foo { nullableError } }") 322 | assert response = @client.query(Temp::Query) 323 | 324 | assert response.data.foo 325 | assert_empty response.data.errors 326 | assert_equal "b00m", response.data.errors.all["foo"][0] 327 | 328 | assert_nil response.data.foo.nullable_error 329 | assert_equal "b00m", response.data.foo.errors["nullableError"][0] 330 | assert_equal "b00m", response.data.foo.errors.all["nullableError"][0] 331 | end 332 | 333 | def test_nonnullable_root_error 334 | Temp.const_set :Query, @client.parse("{ version, nonnullableError }") 335 | assert response = @client.query(Temp::Query) 336 | 337 | assert_nil response.data 338 | refute_empty response.errors 339 | assert_equal "b00m", response.errors[:data][0] 340 | assert_equal "b00m", response.errors.all[:data][0] 341 | end 342 | 343 | def test_nonnullable_nested_error 344 | Temp.const_set :Query, @client.parse("{ version, foo { nonnullableError } }") 345 | assert response = @client.query(Temp::Query) 346 | 347 | assert_nil response.data 348 | refute_empty response.errors 349 | assert_equal "b00m", response.errors[:data][0] 350 | assert_equal "b00m", response.errors.all[:data][0] 351 | end 352 | 353 | def test_collection_errors 354 | Temp.const_set :Query, @client.parse("{ foos { nullableError } }") 355 | assert response = @client.query(Temp::Query) 356 | 357 | assert response.data.foos 358 | assert_empty response.data.errors 359 | assert_equal "b00m", response.data.errors.all["foos"][0] 360 | assert_equal "b00m", response.data.errors.all["foos"][1] 361 | 362 | assert_nil response.data.foos[0].nullable_error 363 | assert_equal "b00m", response.data.foos[0].errors["nullableError"][0] 364 | assert_equal "b00m", response.data.foos[0].errors.all["nullableError"][0] 365 | end 366 | 367 | def test_node_errors 368 | Temp.const_set :Query, @client.parse("{ node { __typename } nodes { __typename } }") 369 | assert response = @client.query(Temp::Query) 370 | 371 | assert_nil response.data.node 372 | # This list-error handling behavior is broken for class-based schemas that don't use the interpreter. 373 | # The fix is an `.is_a?` check in `proxy_to_depth` in member_instrumentation.rb 374 | if defined?(GraphQL::Execution::Interpreter) 375 | assert_nil response.data.nodes[0] 376 | end 377 | assert response.data.nodes[1] 378 | assert_equal "Foo", response.data.nodes[1].__typename 379 | 380 | refute_empty response.data.errors 381 | assert_equal "missing node", response.data.errors["node"][0] 382 | # This error isn't added in class-based schemas + `Execution::Execute`, same bug as above 383 | if defined?(GraphQL::Execution::Interpreter) 384 | assert_equal "missing node", response.data.nodes.errors[0][0] 385 | end 386 | end 387 | end 388 | -------------------------------------------------------------------------------- /test/test_client_fetch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "minitest/autorun" 5 | 6 | class TestClientFetch < Minitest::Test 7 | class QueryType < GraphQL::Schema::Object 8 | field :version, Integer, null: false 9 | def version 10 | 1 11 | end 12 | 13 | field :error, String, null: false 14 | def error 15 | raise GraphQL::ExecutionError, "b00m" 16 | end 17 | 18 | field :partial_error, String, null: true 19 | def partial_error 20 | raise GraphQL::ExecutionError, "just a little broken" 21 | end 22 | 23 | field :variables, Boolean, null: false do 24 | argument :foo, Integer, required: true 25 | end 26 | 27 | def variables(foo:) 28 | foo == 42 29 | end 30 | end 31 | 32 | class Schema < GraphQL::Schema 33 | query(QueryType) 34 | end 35 | 36 | module Temp 37 | end 38 | 39 | def setup 40 | @client = GraphQL::Client.new(schema: Schema, execute: Schema) 41 | end 42 | 43 | def teardown 44 | Temp.constants.each do |sym| 45 | Temp.send(:remove_const, sym) 46 | end 47 | end 48 | 49 | def test_successful_response 50 | Temp.const_set :Query, @client.parse("{ version }") 51 | assert response = @client.query(Temp::Query) 52 | assert_equal 1, response.data.version 53 | assert_empty response.errors 54 | 55 | assert_equal({ 56 | "data" => { 57 | "version" => 1 58 | } 59 | }, response.to_h) 60 | end 61 | 62 | def test_failed_validation_response 63 | query = Class.new(GraphQL::Schema::Object) do 64 | graphql_name "Query" 65 | field :err, String, null: true 66 | end 67 | 68 | outdated_schema = Class.new(GraphQL::Schema) do 69 | query(query) 70 | def self.resolve_type(_type, _obj, _ctx) 71 | raise NotImplementedError 72 | end 73 | end 74 | 75 | @client = GraphQL::Client.new(schema: outdated_schema, execute: Schema) 76 | 77 | Temp.const_set :Query, @client.parse("{ err }") 78 | assert response = @client.query(Temp::Query) 79 | refute response.data 80 | 81 | refute_empty response.errors 82 | assert_includes response.errors[:data][0], "Field 'err' doesn't exist on type 'Query'" 83 | 84 | refute_empty response.errors.all 85 | assert_includes response.errors[:data][0], "Field 'err' doesn't exist on type 'Query'" 86 | end 87 | 88 | def test_failed_response 89 | Temp.const_set :Query, @client.parse("{ error }") 90 | assert response = @client.query(Temp::Query) 91 | refute response.data 92 | 93 | refute_empty response.errors 94 | assert_equal "b00m", response.errors[:data][0] 95 | end 96 | 97 | def test_partial_response 98 | Temp.const_set :Query, @client.parse("{ partialError }") 99 | response = @client.query(Temp::Query) 100 | 101 | assert response.data 102 | assert_nil response.data.partial_error 103 | refute_empty response.data.errors 104 | assert_equal "just a little broken", response.data.errors["partialError"][0] 105 | 106 | assert_empty response.errors 107 | refute_empty response.errors.all 108 | assert_equal "just a little broken", response.errors.all[:data][0] 109 | end 110 | 111 | def test_query_with_string_key_variables 112 | Temp.const_set :Query, @client.parse("query($foo: Int!) { variables(foo: $foo) }") 113 | assert response = @client.query(Temp::Query, variables: { "foo" => 42 }) 114 | assert_empty response.errors 115 | assert_equal true, response.data.variables 116 | end 117 | 118 | def test_query_with_symbol_key_variables 119 | Temp.const_set :Query, @client.parse("query($foo: Int!) { variables(foo: $foo) }") 120 | assert response = @client.query(Temp::Query, variables: { foo: 42 }) 121 | assert_empty response.errors 122 | assert_equal true, response.data.variables 123 | end 124 | 125 | def test_dynamic_query_errors 126 | query = @client.parse("{ version }") 127 | 128 | assert_raises GraphQL::Client::DynamicQueryError do 129 | @client.query(query) 130 | end 131 | 132 | @client.allow_dynamic_queries = true 133 | assert @client.query(query) 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/test_client_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "json" 5 | require "minitest/autorun" 6 | 7 | class TestClientSchema < Minitest::Test 8 | FakeConn = Class.new do 9 | attr_reader :context 10 | 11 | def headers(_) 12 | {} 13 | end 14 | 15 | def execute(document:, operation_name: nil, variables: {}, context: {}) 16 | @context = context 17 | end 18 | end 19 | 20 | class AwesomeQueryType < GraphQL::Schema::Object 21 | field :version, Integer, null: false 22 | end 23 | 24 | class Schema < GraphQL::Schema 25 | query(AwesomeQueryType) 26 | end 27 | 28 | def test_load_schema_identity 29 | schema = GraphQL::Client.load_schema(Schema) 30 | assert_equal "AwesomeQuery", schema.query.graphql_name 31 | end 32 | 33 | def test_load_schema_from_introspection_query_result 34 | result = Schema.execute(GraphQL::Introspection::INTROSPECTION_QUERY) 35 | schema = GraphQL::Client.load_schema(result) 36 | assert_equal "AwesomeQuery", schema.query.graphql_name 37 | end 38 | 39 | def test_load_schema_from_json_string 40 | json = JSON.generate(Schema.execute(GraphQL::Introspection::INTROSPECTION_QUERY)) 41 | schema = GraphQL::Client.load_schema(json) 42 | assert_equal "AwesomeQuery", schema.query.graphql_name 43 | end 44 | 45 | def test_load_schema_ignores_missing_path 46 | refute GraphQL::Client.load_schema("#{__dir__}/missing-schema.json") 47 | end 48 | 49 | def test_dump_schema 50 | schema = GraphQL::Client.dump_schema(Schema) 51 | assert_kind_of Hash, schema 52 | assert_equal "AwesomeQuery", schema["data"]["__schema"]["queryType"]["name"] 53 | end 54 | 55 | def test_dump_schema_io 56 | buffer = StringIO.new 57 | GraphQL::Client.dump_schema(Schema, buffer) 58 | buffer.rewind 59 | assert_equal "{\n \"data\"", buffer.read(10) 60 | end 61 | 62 | def test_dump_schema_context 63 | conn = FakeConn.new 64 | GraphQL::Client.dump_schema(conn, StringIO.new, context: { user_id: 1 }) 65 | assert_equal({ user_id: 1 }, conn.context) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/test_client_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "minitest/autorun" 5 | 6 | class TestClientValidation < Minitest::Test 7 | class UserType < GraphQL::Schema::Object 8 | field :name, String, null: false 9 | end 10 | 11 | class QueryType < GraphQL::Schema::Object 12 | field :viewer, UserType, null: false 13 | end 14 | 15 | class Schema < GraphQL::Schema 16 | query(QueryType) 17 | end 18 | 19 | module Temp 20 | end 21 | 22 | def setup 23 | @client = GraphQL::Client.new(schema: Schema) 24 | end 25 | 26 | def teardown 27 | Temp.constants.each do |sym| 28 | Temp.send(:remove_const, sym) 29 | end 30 | end 31 | 32 | def test_client_parse_query_missing_local_fragment 33 | Temp.const_set :FooQuery, @client.parse(<<-'GRAPHQL') 34 | query { 35 | ...MissingFragment 36 | } 37 | GRAPHQL 38 | rescue GraphQL::Client::ValidationError => e 39 | assert_equal "#{__FILE__}:#{__LINE__ - 4}", e.backtrace.first 40 | assert_equal "uninitialized constant MissingFragment", e.message 41 | else 42 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 43 | end 44 | 45 | def test_client_parse_fragment_missing_local_fragment 46 | Temp.const_set :FooFragment, @client.parse(<<-'GRAPHQL') 47 | query { 48 | ...MissingFragment 49 | } 50 | GRAPHQL 51 | rescue GraphQL::Client::ValidationError => e 52 | assert_equal "#{__FILE__}:#{__LINE__ - 4}", e.backtrace.first 53 | assert_equal "uninitialized constant MissingFragment", e.message 54 | else 55 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 56 | end 57 | 58 | def test_client_parse_query_missing_external_fragment 59 | Temp.const_set :FooQuery, @client.parse(<<-'GRAPHQL') 60 | query { 61 | ...TestClientValidation::Temp::MissingFragment 62 | } 63 | GRAPHQL 64 | rescue GraphQL::Client::ValidationError => e 65 | assert_equal "#{__FILE__}:#{__LINE__ - 4}", e.backtrace.first 66 | assert_equal "uninitialized constant TestClientValidation::Temp::MissingFragment", e.message 67 | else 68 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 69 | end 70 | 71 | def test_client_parse_query_external_fragment_is_wrong_type 72 | Temp.const_set :Answer, 42 73 | 74 | Temp.const_set :FooQuery, @client.parse(<<-'GRAPHQL') 75 | query { 76 | ...TestClientValidation::Temp::Answer 77 | } 78 | GRAPHQL 79 | rescue GraphQL::Client::ValidationError => e 80 | assert_equal "#{__FILE__}:#{__LINE__ - 4}", e.backtrace.first 81 | assert_equal "expected TestClientValidation::Temp::Answer to be a " \ 82 | "GraphQL::Client::FragmentDefinition, but was a #{42.class}.", e.message 83 | else 84 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 85 | end 86 | 87 | def test_client_parse_query_external_fragment_is_module 88 | Temp.const_set :UserDocument, @client.parse(<<-'GRAPHQL') 89 | fragment UserFragment on User { 90 | __typename 91 | } 92 | GRAPHQL 93 | 94 | Temp.const_set :FooQuery, @client.parse(<<-'GRAPHQL') 95 | query { 96 | ...TestClientValidation::Temp::UserDocument 97 | } 98 | GRAPHQL 99 | rescue GraphQL::Client::ValidationError => e 100 | assert_equal "#{__FILE__}:#{__LINE__ - 4}", e.backtrace.first 101 | assert_equal "expected TestClientValidation::Temp::UserDocument to be a " \ 102 | "GraphQL::Client::FragmentDefinition, but was a Module. Did you mean " \ 103 | "TestClientValidation::Temp::UserDocument::UserFragment?", e.message 104 | else 105 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 106 | end 107 | 108 | def test_client_parse_with_missing_type 109 | Temp.const_set :UserFragment, @client.parse(<<-'GRAPHQL') 110 | fragment on MissingType { 111 | __typename 112 | } 113 | GRAPHQL 114 | rescue GraphQL::Client::ValidationError => e 115 | assert_equal "#{__FILE__}:#{__LINE__ - 5}", e.backtrace.first 116 | assert_equal "No such type MissingType, so it can't be a fragment condition", e.message 117 | else 118 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 119 | end 120 | 121 | def test_client_parse_with_missing_field 122 | Temp.const_set :UserFragment, @client.parse(<<-'GRAPHQL') 123 | fragment on User { 124 | __typename 125 | missingField 126 | } 127 | GRAPHQL 128 | rescue GraphQL::Client::ValidationError => e 129 | assert_equal "#{__FILE__}:#{__LINE__ - 4}", e.backtrace.first 130 | assert_equal "Field 'missingField' doesn't exist on type 'User'", e.message 131 | else 132 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 133 | end 134 | 135 | def test_client_parse_with_missing_nested_field 136 | Temp.const_set :UserQuery, @client.parse(<<-'GRAPHQL') 137 | query { 138 | viewer { 139 | name 140 | missingField 141 | } 142 | } 143 | GRAPHQL 144 | rescue GraphQL::Client::ValidationError => e 145 | assert_equal "#{__FILE__}:#{__LINE__ - 5}", e.backtrace.first 146 | assert_equal "Field 'missingField' doesn't exist on type 'User'", e.message 147 | else 148 | flunk "GraphQL::Client::ValidationError expected but nothing was raised" 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/test_collocated_enforcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/collocated_enforcement" 3 | require "minitest/autorun" 4 | require_relative "foo_helper" 5 | 6 | class TestCollocatedEnforcement < Minitest::Test 7 | include FooHelper 8 | 9 | class Person 10 | extend GraphQL::Client::CollocatedEnforcement 11 | 12 | def name 13 | "Josh" 14 | end 15 | 16 | def company 17 | "GitHub" 18 | end 19 | 20 | def company? 21 | true 22 | end 23 | 24 | enforce_collocated_callers(self, %w(name company company?), __FILE__) 25 | end 26 | 27 | def test_enforce_collocated_callers 28 | person = Person.new 29 | 30 | assert_equal "Josh", person.name 31 | assert_equal "GitHub", person.company 32 | assert_equal true, person.company? 33 | assert_equal "Josh", person.public_send(:name) 34 | 35 | GraphQL::Client.allow_noncollocated_callers do 36 | assert_equal "Josh works at GitHub", format_person_info(person) 37 | end 38 | 39 | assert_raises GraphQL::Client::NonCollocatedCallerError do 40 | format_person_info(person) 41 | end 42 | 43 | GraphQL::Client.allow_noncollocated_callers do 44 | assert_equal true, person_employed?(person) 45 | end 46 | 47 | assert_raises GraphQL::Client::NonCollocatedCallerError do 48 | person_employed?(person) 49 | end 50 | 51 | GraphQL::Client.allow_noncollocated_callers do 52 | assert_equal "Josh works at GitHub", format_person_info_via_send(person) 53 | end 54 | 55 | assert_raises GraphQL::Client::NonCollocatedCallerError do 56 | format_person_info_via_send(person) 57 | end 58 | end 59 | 60 | def test_exception_backtrace_excludes_enforce_collocated_callers 61 | person = Person.new 62 | begin 63 | format_person_info(person) 64 | rescue GraphQL::Client::NonCollocatedCallerError => e 65 | exception = e 66 | end 67 | 68 | assert_includes exception.backtrace[0], "in `format_person_info'" 69 | assert_includes exception.backtrace[1], "in `test_exception_backtrace_excludes_enforce_collocated_callers'" 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/test_definition_variables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "graphql/client/definition_variables" 5 | require "minitest/autorun" 6 | 7 | class TestDefinitionVariables < Minitest::Test 8 | class QueryType < GraphQL::Schema::Object 9 | field :version, Integer, null: false 10 | field :user, String, null: false do 11 | argument :name, String, required: true 12 | argument :maybe_name, String, required: false 13 | end 14 | field :node, String, null: false do 15 | argument :id, ID, required: true 16 | end 17 | end 18 | 19 | class CreateUserInput < GraphQL::Schema::InputObject 20 | argument :name, String, required: true 21 | end 22 | 23 | class MutationType < GraphQL::Schema::Object 24 | field :create_user, String, null: true do 25 | argument :input, CreateUserInput, required: true 26 | end 27 | end 28 | 29 | class Schema < GraphQL::Schema 30 | query(QueryType) 31 | mutation(MutationType) 32 | end 33 | 34 | def test_query_with_no_variables 35 | document = GraphQL.parse <<-'GRAPHQL' 36 | query { 37 | version 38 | } 39 | GRAPHQL 40 | definition = document.definitions[0] 41 | 42 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 43 | assert variables.empty? 44 | end 45 | 46 | def test_fragment_with_no_variables 47 | document = GraphQL.parse <<-'GRAPHQL' 48 | fragment on Query { 49 | version 50 | } 51 | GRAPHQL 52 | definition = document.definitions[0] 53 | 54 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 55 | assert variables.empty? 56 | end 57 | 58 | def test_query_with_one_variable 59 | document = GraphQL.parse <<-'GRAPHQL' 60 | query { 61 | user(name: $name) 62 | } 63 | GRAPHQL 64 | definition = document.definitions[0] 65 | 66 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 67 | assert variables[:name].kind.non_null? 68 | assert_equal "String", variables[:name].unwrap.graphql_name 69 | 70 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 71 | assert_equal ["$name: String!"], variables.map(&:to_query_string) 72 | end 73 | 74 | def test_query_with_one_nested_variable 75 | document = GraphQL.parse <<-'GRAPHQL' 76 | query { 77 | ...Foo 78 | } 79 | 80 | fragment Foo on Query { 81 | user(name: $name) 82 | } 83 | GRAPHQL 84 | definition = document.definitions[0] 85 | 86 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 87 | assert variables[:name].kind.non_null? 88 | assert_equal "String", variables[:name].unwrap.graphql_name 89 | 90 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 91 | assert_equal ["$name: String!"], variables.map(&:to_query_string) 92 | end 93 | 94 | def test_query_with_unused_nested_variable 95 | document = GraphQL.parse <<-'GRAPHQL' 96 | query { 97 | ...One 98 | } 99 | 100 | fragment One on Query { 101 | one: user(name: $one) 102 | } 103 | 104 | fragment Two on Query { 105 | two: user(name: $two) 106 | } 107 | GRAPHQL 108 | definition = document.definitions[0] 109 | 110 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 111 | assert variables[:one].kind.non_null? 112 | assert_equal "String", variables[:one].unwrap.graphql_name 113 | assert_equal false, variables.key?(:two) 114 | 115 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 116 | assert_equal ["$one: String!"], variables.map(&:to_query_string) 117 | end 118 | 119 | def test_query_nullable_and_nonnullable_variables 120 | document = GraphQL.parse <<-'GRAPHQL' 121 | query { 122 | one: user(name: $foo, maybeName: $bar) 123 | } 124 | GRAPHQL 125 | definition = document.definitions[0] 126 | 127 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 128 | assert variables[:foo].kind.non_null? 129 | assert_equal "String", variables[:foo].unwrap.graphql_name 130 | assert_equal "String", variables[:bar].graphql_name 131 | 132 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 133 | assert_equal ["$foo: String!", "$bar: String"], variables.map(&:to_query_string) 134 | end 135 | 136 | 137 | def test_query_variable_used_twice 138 | document = GraphQL.parse <<-'GRAPHQL' 139 | query { 140 | one: user(name: $name) 141 | two: user(name: $name) 142 | } 143 | GRAPHQL 144 | definition = document.definitions[0] 145 | 146 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 147 | assert variables[:name].kind.non_null? 148 | assert_equal "String", variables[:name].unwrap.graphql_name 149 | 150 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 151 | assert_equal ["$name: String!"], variables.map(&:to_query_string) 152 | end 153 | 154 | def test_query_same_nullable_and_nonnullable_variables 155 | document = GraphQL.parse <<-'GRAPHQL' 156 | query { 157 | one: user(name: $foo, maybeName: $foo) 158 | two: user(maybeName: $bar, name: $bar) 159 | } 160 | GRAPHQL 161 | definition = document.definitions[0] 162 | 163 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 164 | assert variables[:foo].kind.non_null? 165 | assert_equal "String", variables[:foo].unwrap.graphql_name 166 | assert variables[:bar].kind.non_null? 167 | assert_equal "String", variables[:bar].unwrap.graphql_name 168 | 169 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 170 | assert_equal ["$foo: String!", "$bar: String!"], variables.map(&:to_query_string) 171 | end 172 | 173 | def test_fragment_with_unused_nested_variable 174 | document = GraphQL.parse <<-'GRAPHQL' 175 | fragment Root on Query { 176 | ...One 177 | } 178 | 179 | fragment One on Query { 180 | one: user(name: $one) 181 | } 182 | 183 | fragment Two on Query { 184 | two: user(name: $two) 185 | } 186 | GRAPHQL 187 | definition = document.definitions[0] 188 | 189 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 190 | assert variables[:one].kind.non_null? 191 | assert_equal "String", variables[:one].unwrap.graphql_name 192 | assert_equal false, variables.key?(:two) 193 | 194 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 195 | assert_equal ["$one: String!"], variables.map(&:to_query_string) 196 | end 197 | 198 | def test_mutation_with_input_type_variable 199 | document = GraphQL.parse <<-'GRAPHQL' 200 | mutation { 201 | createUser(input: $input) 202 | } 203 | GRAPHQL 204 | definition = document.definitions[0] 205 | 206 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 207 | assert variables[:input].kind.non_null? 208 | assert_equal "CreateUserInput", variables[:input].unwrap.graphql_name 209 | 210 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 211 | assert_equal ["$input: CreateUserInput!"], variables.map(&:to_query_string) 212 | end 213 | 214 | def test_mutation_with_nested_input_type_variable 215 | document = GraphQL.parse <<-'GRAPHQL' 216 | mutation { 217 | createUser(input: { name: $name }) 218 | } 219 | GRAPHQL 220 | definition = document.definitions[0] 221 | 222 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 223 | assert variables[:name].kind.non_null? 224 | assert_equal "String", variables[:name].unwrap.graphql_name 225 | 226 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 227 | assert_equal ["$name: String!"], variables.map(&:to_query_string) 228 | end 229 | 230 | def test_query_with_one_directive_variables 231 | document = GraphQL.parse <<-'GRAPHQL' 232 | query { 233 | version @skip(if: $should_skip) 234 | } 235 | GRAPHQL 236 | definition = document.definitions[0] 237 | 238 | variables = GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 239 | assert variables[:should_skip].kind.non_null? 240 | assert_equal "Boolean", variables[:should_skip].unwrap.graphql_name 241 | 242 | variables = GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 243 | assert_equal ["$should_skip: Boolean!"], variables.map(&:to_query_string) 244 | end 245 | 246 | def test_query_with_conflicting_variable_types 247 | document = GraphQL.parse <<-'GRAPHQL' 248 | query { 249 | node(id: $id) 250 | user(name: $id) 251 | } 252 | GRAPHQL 253 | definition = document.definitions[0] 254 | 255 | assert_raises GraphQL::Client::ValidationError do 256 | GraphQL::Client::DefinitionVariables.variables(Schema, document, definition.name) 257 | end 258 | 259 | assert_raises GraphQL::Client::ValidationError do 260 | GraphQL::Client::DefinitionVariables.operation_variables(Schema, document, definition.name) 261 | end 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /test/test_erb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "erubi" 3 | require "erubis" 4 | require "graphql" 5 | require "graphql/client/erb" 6 | require "graphql/client/erubi_enhancer" 7 | require "graphql/client/erubis_enhancer" 8 | require "graphql/client/view_module" 9 | require "minitest/autorun" 10 | 11 | class TestERB < Minitest::Test 12 | class ErubiEngine < Erubi::Engine 13 | include GraphQL::Client::ErubiEnhancer 14 | end 15 | 16 | def test_no_graphql_section 17 | src = <<-ERB 18 | <%= 42 %> 19 | ERB 20 | assert_nil GraphQL::Client::ViewModule.extract_graphql_section(src) 21 | end 22 | 23 | def test_erubis_graphql_section 24 | src = <<-ERB 25 | <%# Some comment %> 26 | <%graphql 27 | query { 28 | viewer { 29 | login 30 | } 31 | } 32 | %> 33 | <%= 42 %> 34 | ERB 35 | 36 | erb = GraphQL::Client::ERB.new(src) 37 | 38 | output_buffer = @output_buffer = ActionView::OutputBuffer.new 39 | # rubocop:disable Security/Eval 40 | eval(erb.src, binding, "(erb)") 41 | assert_equal "42", output_buffer.to_s.strip 42 | 43 | expected_query = <<-ERB 44 | query { 45 | viewer { 46 | login 47 | } 48 | } 49 | ERB 50 | 51 | actual_query, lineno = GraphQL::Client::ViewModule.extract_graphql_section(src) 52 | assert_equal 2, lineno 53 | assert_equal expected_query.gsub(" ", "").strip, actual_query.gsub(" ", "").strip 54 | end 55 | 56 | def test_erubi_graphql_section 57 | src = <<-ERB 58 | <%# Some comment %> 59 | <%graphql 60 | query { 61 | viewer { 62 | login 63 | } 64 | } 65 | %> 66 | <%= 42 %> 67 | ERB 68 | 69 | engine = ErubiEngine.new(src) 70 | assert_equal "42", eval(engine.src).strip # rubocop:disable Security/Eval 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/test_hash_with_indifferent_access.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql/client/hash_with_indifferent_access" 3 | require "minitest/autorun" 4 | 5 | class TestHashWithIndifferentAccess < Minitest::Test 6 | def test_string_access 7 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 8 | assert_equal 42, hash["foo"] 9 | assert_equal 42, hash.fetch("foo") 10 | assert hash.key?("foo") 11 | end 12 | 13 | def test_symbol_access 14 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 15 | assert_equal 42, hash[:foo] 16 | assert_equal 42, hash.fetch(:foo) 17 | assert hash.key?(:foo) 18 | end 19 | 20 | def test_snakecase_access 21 | hash = GraphQL::Client::HashWithIndifferentAccess.new("HashWithIndifferentAccess" => 42) 22 | assert_equal 42, hash["hash_with_indifferent_access"] 23 | assert_equal 42, hash.fetch("hash_with_indifferent_access") 24 | assert hash.key?("hash_with_indifferent_access") 25 | end 26 | 27 | def test_integer_access 28 | hash = GraphQL::Client::HashWithIndifferentAccess.new(42 => "foo") 29 | assert_equal "foo", hash[42] 30 | assert_equal "foo", hash.fetch(42) 31 | assert hash.key?(42) 32 | end 33 | 34 | def test_keys 35 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 36 | assert_equal ["foo"], hash.keys 37 | end 38 | 39 | def test_values 40 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 41 | assert_equal [42], hash.values 42 | end 43 | 44 | def test_enumerable_any 45 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 46 | assert hash.any? { |k, _v| k == "foo" } 47 | refute hash.any? { |k, _v| k == "bar" } 48 | end 49 | 50 | def test_empty 51 | hash = GraphQL::Client::HashWithIndifferentAccess.new 52 | assert hash.empty? 53 | 54 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 55 | refute hash.empty? 56 | end 57 | 58 | def test_length 59 | hash = GraphQL::Client::HashWithIndifferentAccess.new 60 | assert_equal 0, hash.length 61 | 62 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 63 | assert_equal 1, hash.length 64 | end 65 | 66 | def test_size 67 | hash = GraphQL::Client::HashWithIndifferentAccess.new 68 | assert_equal 0, hash.size 69 | 70 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 71 | assert_equal 1, hash.size 72 | end 73 | 74 | def test_inspect 75 | hash = GraphQL::Client::HashWithIndifferentAccess.new("foo" => 42) 76 | assert_equal hash.to_h.inspect, hash.inspect 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/test_http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client/http" 4 | require "minitest/autorun" 5 | 6 | class TestHTTP < Minitest::Test 7 | SWAPI = GraphQL::Client::HTTP.new("https://mpjk0plp9.lp.gql.zone/graphql") do 8 | def headers(_context) 9 | { "User-Agent" => "GraphQL/1.0" } 10 | end 11 | end 12 | 13 | def test_execute 14 | skip "TestHTTP disabled by default" unless __FILE__ == $PROGRAM_NAME 15 | 16 | document = GraphQL.parse(<<-'GRAPHQL') 17 | query getCharacter($id: ID!) { 18 | character(id: $id) { 19 | name 20 | } 21 | } 22 | GRAPHQL 23 | 24 | name = "getCharacter" 25 | variables = { "id" => "1001" } 26 | 27 | expected = { 28 | "data" => { 29 | "character" => { 30 | "name" => "Darth Vader" 31 | } 32 | } 33 | } 34 | actual = SWAPI.execute(document: document, operation_name: name, variables: variables) 35 | assert_equal(expected, actual) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/test_object_typename.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client" 4 | require "minitest/autorun" 5 | require "ostruct" 6 | 7 | class TestObjectTypename < Minitest::Test 8 | class PersonType < GraphQL::Schema::Object 9 | field :id, Integer, null: true 10 | def id; 42; end 11 | 12 | field :friends, "TestObjectTypename::PersonConnection", null: true 13 | def friends; [OpenStruct.new, OpenStruct.new]; end 14 | 15 | field :events, "TestObjectTypename::EventConnection", null: true 16 | def events; [OpenStruct.new(type: PublicEventType), OpenStruct.new(type: PrivateEventType)]; end 17 | 18 | field :next_event, "TestObjectTypename::EventInterface", null: true 19 | def next_event 20 | OpenStruct.new(type: PublicEventType) 21 | end 22 | end 23 | 24 | module EventInterface 25 | include GraphQL::Schema::Interface 26 | field :id, Integer, null: true 27 | def id; 42; end 28 | end 29 | 30 | class PublicEventType < GraphQL::Schema::Object 31 | implements EventInterface 32 | end 33 | 34 | class PrivateEventType < GraphQL::Schema::Object 35 | implements EventInterface 36 | end 37 | 38 | class Event < GraphQL::Schema::Union 39 | possible_types PublicEventType, PrivateEventType 40 | end 41 | 42 | PersonConnection = PersonType.connection_type 43 | EventConnection = Event.connection_type 44 | 45 | class QueryType < GraphQL::Schema::Object 46 | field :me, PersonType, null: false 47 | def me; OpenStruct.new; end 48 | end 49 | 50 | class Schema < GraphQL::Schema 51 | query(QueryType) 52 | def self.resolve_type(_type, obj, _ctx) 53 | obj.type 54 | end 55 | end 56 | 57 | module Temp 58 | end 59 | 60 | def setup 61 | @client = GraphQL::Client.new(schema: Schema, execute: Schema) 62 | end 63 | 64 | def teardown 65 | Temp.constants.each do |sym| 66 | Temp.send(:remove_const, sym) 67 | end 68 | end 69 | 70 | def test_define_simple_query_result 71 | Temp.const_set :Query, @client.parse(<<-'GRAPHQL') 72 | { 73 | me { 74 | id 75 | nextEvent { 76 | id 77 | } 78 | friends { 79 | edges { 80 | node { 81 | id 82 | } 83 | } 84 | } 85 | events { 86 | edges { 87 | node { 88 | ... on PublicEvent { 89 | id 90 | } 91 | ... on PrivateEvent { 92 | id 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | GRAPHQL 100 | 101 | response = @client.query(Temp::Query) 102 | assert data = response.data 103 | 104 | assert_equal "Person", data.me.class.type.graphql_name 105 | 106 | assert_equal "PersonConnection", data.me.friends.class.type.graphql_name 107 | assert_equal %w(PersonEdge PersonEdge), data.me.friends.edges.map { |obj| obj.class.type.graphql_name } 108 | assert_equal %w(Person Person), data.me.friends.edges.map(&:node).map { |obj| obj.class.type.graphql_name } 109 | 110 | assert_equal "EventConnection", data.me.events.class.type.graphql_name 111 | assert_equal %w(EventEdge EventEdge), data.me.events.edges.map { |obj| obj.class.type.graphql_name } 112 | assert_equal %w(PublicEvent PrivateEvent), data.me.events.edges.map(&:node).map { |obj| obj.class.type.graphql_name } 113 | 114 | assert_equal "PublicEvent", data.me.next_event.class.type.graphql_name 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/test_operation_slice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "minitest/autorun" 4 | 5 | class TestDefinitionSlice < Minitest::Test 6 | def test_slice_simple_query_operation 7 | document = GraphQL.parse(<<-'GRAPHQL') 8 | query FooQuery { 9 | node(id: "42") { 10 | id 11 | } 12 | } 13 | GRAPHQL 14 | 15 | new_document = GraphQL::Language::DefinitionSlice.slice(document, "FooQuery") 16 | 17 | expected = <<-'GRAPHQL' 18 | query FooQuery { 19 | node(id: "42") { 20 | id 21 | } 22 | } 23 | GRAPHQL 24 | assert_equal expected.gsub(/^ /, "").chomp, new_document.to_query_string 25 | end 26 | 27 | def test_slice_simple_mutation_operation 28 | document = GraphQL.parse(<<-'GRAPHQL') 29 | mutation FooMutation { 30 | incr { 31 | count 32 | } 33 | } 34 | GRAPHQL 35 | 36 | new_document = GraphQL::Language::DefinitionSlice.slice(document, "FooMutation") 37 | 38 | expected = <<-'GRAPHQL' 39 | mutation FooMutation { 40 | incr { 41 | count 42 | } 43 | } 44 | GRAPHQL 45 | assert_equal expected.gsub(/^ /, "").chomp, new_document.to_query_string 46 | end 47 | 48 | def test_slice_query_with_fragment 49 | document = GraphQL.parse(<<-'GRAPHQL') 50 | query FooQuery { 51 | node(id: "42") { 52 | ...NodeFragment 53 | } 54 | } 55 | 56 | fragment NodeFragment on Node { 57 | id 58 | } 59 | 60 | fragment UnusedFragment on Node { 61 | type 62 | } 63 | GRAPHQL 64 | 65 | new_document = GraphQL::Language::DefinitionSlice.slice(document, "FooQuery") 66 | 67 | expected = <<-'GRAPHQL' 68 | query FooQuery { 69 | node(id: "42") { 70 | ...NodeFragment 71 | } 72 | } 73 | 74 | fragment NodeFragment on Node { 75 | id 76 | } 77 | GRAPHQL 78 | assert_equal expected.gsub(/^ /, "").chomp, new_document.to_query_string 79 | end 80 | 81 | def test_slice_nested_query_with_fragment 82 | document = GraphQL.parse(<<-'GRAPHQL') 83 | fragment NodeFragment on Node { 84 | id 85 | ...UserFragment 86 | ...AnotherUserFragment 87 | } 88 | 89 | query FooQuery { 90 | node(id: "42") { 91 | ...NodeFragment 92 | } 93 | } 94 | 95 | fragment AnotherUnusedFragment on Project { 96 | number 97 | } 98 | 99 | fragment AnotherUserFragment on Node { 100 | company 101 | } 102 | 103 | fragment UserFragment on Node { 104 | name 105 | ...AnotherUserFragment 106 | } 107 | 108 | fragment UnusedFragment on Node { 109 | type 110 | ...AnotherUnusedFragment 111 | } 112 | GRAPHQL 113 | 114 | new_document = GraphQL::Language::DefinitionSlice.slice(document, "FooQuery") 115 | 116 | expected = <<-'GRAPHQL' 117 | fragment NodeFragment on Node { 118 | id 119 | ...UserFragment 120 | ...AnotherUserFragment 121 | } 122 | 123 | query FooQuery { 124 | node(id: "42") { 125 | ...NodeFragment 126 | } 127 | } 128 | 129 | fragment AnotherUserFragment on Node { 130 | company 131 | } 132 | 133 | fragment UserFragment on Node { 134 | name 135 | ...AnotherUserFragment 136 | } 137 | GRAPHQL 138 | assert_equal expected.gsub(/^ /, "").chomp, new_document.to_query_string 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/test_query_typename.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "graphql" 3 | require "graphql/client/query_typename" 4 | require "minitest/autorun" 5 | 6 | class TestQueryTypename < Minitest::Test 7 | class PersonType < GraphQL::Schema::Object 8 | field :id, Integer, null: true 9 | def id; 42; end 10 | 11 | field :friends, "TestQueryTypename::PersonConnection", null: true 12 | def friends; [OpenStruct.new, OpenStruct.new]; end 13 | 14 | field :events, "TestQueryTypename::EventConnection", null: true 15 | def events; [OpenStruct.new(type: PublicEventType), OpenStruct.new(type: PrivateEventType)]; end 16 | 17 | field :next_event, "TestQueryTypename::EventInterface", null: true 18 | def next_event 19 | OpenStruct.new(type: PublicEventType) 20 | end 21 | end 22 | 23 | module EventInterface 24 | include GraphQL::Schema::Interface 25 | field :id, Integer, null: true 26 | def id; 42; end 27 | end 28 | 29 | class PublicEventType < GraphQL::Schema::Object 30 | implements EventInterface 31 | end 32 | 33 | class PrivateEventType < GraphQL::Schema::Object 34 | implements EventInterface 35 | end 36 | 37 | class Event < GraphQL::Schema::Union 38 | possible_types PublicEventType, PrivateEventType 39 | end 40 | 41 | PersonConnection = PersonType.connection_type 42 | EventConnection = Event.connection_type 43 | 44 | class QueryType < GraphQL::Schema::Object 45 | field :me, PersonType, null: false 46 | def me; OpenStruct.new; end 47 | end 48 | 49 | class Schema < GraphQL::Schema 50 | query(QueryType) 51 | def self.resolve_type(_type, obj, _ctx) 52 | obj.type 53 | end 54 | end 55 | 56 | def setup 57 | @document = GraphQL.parse(<<-'GRAPHQL') 58 | query FooQuery { 59 | me { 60 | id 61 | __typename 62 | ...PersonFragment 63 | nextEvent { 64 | id 65 | ...EventFragment 66 | } 67 | friends { 68 | edges { 69 | node { 70 | id 71 | } 72 | } 73 | } 74 | events { 75 | edges { 76 | node { 77 | ... on PublicEvent { 78 | id 79 | } 80 | ... on PrivateEvent { 81 | id 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | fragment PersonFragment on Person { 90 | id 91 | nextEvent { 92 | id 93 | } 94 | } 95 | 96 | fragment EventFragment on Event { 97 | id 98 | } 99 | GRAPHQL 100 | end 101 | 102 | def test_insert_typename 103 | document = GraphQL::Client::QueryTypename.insert_typename_fields(@document) 104 | 105 | expected = <<-'GRAPHQL' 106 | query FooQuery { 107 | __typename 108 | me { 109 | id 110 | __typename 111 | ...PersonFragment 112 | nextEvent { 113 | __typename 114 | id 115 | ...EventFragment 116 | } 117 | friends { 118 | __typename 119 | edges { 120 | __typename 121 | node { 122 | __typename 123 | id 124 | } 125 | } 126 | } 127 | events { 128 | __typename 129 | edges { 130 | __typename 131 | node { 132 | __typename 133 | ... on PublicEvent { 134 | id 135 | } 136 | ... on PrivateEvent { 137 | id 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | fragment PersonFragment on Person { 146 | __typename 147 | id 148 | nextEvent { 149 | __typename 150 | id 151 | } 152 | } 153 | 154 | fragment EventFragment on Event { 155 | __typename 156 | id 157 | } 158 | GRAPHQL 159 | assert_equal expected.gsub(/^ /, "").chomp, document.to_query_string 160 | end 161 | 162 | def test_insert_schema_aware_typename 163 | types = GraphQL::Client::DocumentTypes.analyze_types(Schema, @document) 164 | document = GraphQL::Client::QueryTypename.insert_typename_fields(@document, types: types) 165 | 166 | expected = <<-'GRAPHQL' 167 | query FooQuery { 168 | me { 169 | id 170 | __typename 171 | ...PersonFragment 172 | nextEvent { 173 | __typename 174 | id 175 | ...EventFragment 176 | } 177 | friends { 178 | edges { 179 | node { 180 | id 181 | } 182 | } 183 | } 184 | events { 185 | edges { 186 | node { 187 | __typename 188 | ... on PublicEvent { 189 | id 190 | } 191 | ... on PrivateEvent { 192 | id 193 | } 194 | } 195 | } 196 | } 197 | } 198 | } 199 | 200 | fragment PersonFragment on Person { 201 | id 202 | nextEvent { 203 | __typename 204 | id 205 | } 206 | } 207 | 208 | fragment EventFragment on Event { 209 | __typename 210 | id 211 | } 212 | GRAPHQL 213 | assert_equal expected.gsub(/^ /, "").chomp, document.to_query_string 214 | end 215 | 216 | def test_insert_typename_on_empty_selections 217 | document = GraphQL.parse(<<-'GRAPHQL') 218 | query FooQuery { 219 | me 220 | } 221 | GRAPHQL 222 | 223 | types = GraphQL::Client::DocumentTypes.analyze_types(Schema, document) 224 | document = GraphQL::Client::QueryTypename.insert_typename_fields(document, types: types) 225 | 226 | expected = <<-'GRAPHQL' 227 | query FooQuery { 228 | me { 229 | __typename 230 | } 231 | } 232 | GRAPHQL 233 | assert_equal expected.gsub(/^ /, "").chomp, document.to_query_string 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /test/test_rubocop_heredoc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rubocop/cop/graphql/heredoc" 3 | require "minitest/autorun" 4 | 5 | class TestRubocopHeredoc < Minitest::Test 6 | def setup 7 | config = RuboCop::Config.new 8 | @cop = RuboCop::Cop::GraphQL::Heredoc.new(config) 9 | end 10 | 11 | def test_good_graphql_heredoc 12 | result = investigate(@cop, <<-RUBY) 13 | Query = Client.parse <<'GRAPHQL' 14 | { version } 15 | GRAPHQL 16 | RUBY 17 | 18 | assert_empty result.offenses.map(&:message) 19 | end 20 | 21 | def test_good_graphql_dash_heredoc 22 | result = investigate(@cop, <<-RUBY) 23 | Query = Client.parse <<-'GRAPHQL' 24 | { version } 25 | GRAPHQL 26 | RUBY 27 | 28 | assert_empty result.offenses.map(&:message) 29 | end 30 | 31 | def test_good_graphql_squiggly_heredoc 32 | result = investigate(@cop, <<-RUBY) 33 | Query = Client.parse <<~'GRAPHQL' 34 | { version } 35 | GRAPHQL 36 | RUBY 37 | 38 | assert_empty result.offenses.map(&:message) 39 | end 40 | 41 | def test_bad_graphql_heredoc 42 | result = investigate(@cop, <<-RUBY) 43 | Query = Client.parse < 8 | <%= user.login %> 9 | -------------------------------------------------------------------------------- /test/views/users/overfetch.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment User on User { 3 | login 4 | birthday 5 | 6 | posts(first: 10) { 7 | edges { 8 | node { 9 | title 10 | } 11 | } 12 | } 13 | } 14 | %> 15 | <%= user.login %> 16 | 17 | <%- user.posts.edges.map(&:node).map(&:title).each do |title| %> 18 |

<%= title %>

19 | <%- end %> 20 | -------------------------------------------------------------------------------- /test/views/users/profile/_show.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment User on User { 3 | login 4 | } 5 | %> 6 | <%= user.login %> 7 | -------------------------------------------------------------------------------- /test/views/users/show-2-3.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment User on User { 3 | login 4 | } 5 | %> 6 | <%= user&.login %> 7 | -------------------------------------------------------------------------------- /test/views/users/show.html.erb: -------------------------------------------------------------------------------- 1 | <%graphql 2 | fragment User on User { 3 | login 4 | } 5 | %> 6 | <%= user.login %> 7 | --------------------------------------------------------------------------------