├── .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 [](https://badge.fury.io/rb/graphql-client) [](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 |
--------------------------------------------------------------------------------