├── examples
├── file_uploads
│ ├── file.txt
│ ├── Procfile
│ ├── Gemfile
│ ├── remote.rb
│ ├── gateway.rb
│ ├── README.md
│ └── helpers.rb
├── subscriptions
│ ├── public
│ │ ├── favicon.ico
│ │ ├── apple-touch-icon.png
│ │ ├── apple-touch-icon-precomposed.png
│ │ ├── robots.txt
│ │ ├── 500.html
│ │ ├── 422.html
│ │ └── 404.html
│ ├── config
│ │ ├── master.key
│ │ ├── environment.rb
│ │ ├── boot.rb
│ │ ├── routes.rb
│ │ ├── cable.yml
│ │ ├── application.rb
│ │ ├── credentials.yml.enc
│ │ ├── initializers
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── permissions_policy.rb
│ │ │ ├── inflections.rb
│ │ │ └── content_security_policy.rb
│ │ ├── database.yml
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── storage.yml
│ │ ├── puma.rb
│ │ └── environments
│ │ │ ├── test.rb
│ │ │ └── development.rb
│ ├── bin
│ │ ├── rake
│ │ ├── importmap
│ │ ├── rails
│ │ ├── docker-entrypoint
│ │ ├── setup
│ │ └── bundle
│ ├── config.ru
│ ├── Rakefile
│ ├── app
│ │ ├── graphql
│ │ │ ├── stitched_schema.rb
│ │ │ ├── entities_schema.rb
│ │ │ └── subscriptions_schema.rb
│ │ ├── models
│ │ │ └── repository.rb
│ │ ├── controllers
│ │ │ └── graphql_controller.rb
│ │ └── channels
│ │ │ └── graphql_channel.rb
│ ├── .gitattributes
│ ├── db
│ │ └── seeds.rb
│ ├── .gitignore
│ ├── README.md
│ └── Gemfile
└── merged_types
│ ├── Procfile
│ ├── Gemfile
│ ├── remote1.rb
│ ├── remote2.rb
│ ├── README.md
│ └── gateway.rb
├── docs
├── images
│ ├── library.png
│ ├── merging.png
│ └── stitching.png
├── README.md
├── merged_types_apollo.md
├── error_handling.md
└── query_planning.md
├── .yardopts
├── lib
└── graphql
│ ├── stitching
│ ├── version.rb
│ ├── composer
│ │ ├── base_validator.rb
│ │ ├── type_resolver_config.rb
│ │ └── validate_interfaces.rb
│ ├── directives.rb
│ ├── supergraph
│ │ ├── types.rb
│ │ └── from_definition.rb
│ ├── type_resolver.rb
│ ├── planner
│ │ └── step.rb
│ ├── plan.rb
│ ├── executor
│ │ └── root_source.rb
│ ├── request
│ │ └── skip_include.rb
│ ├── executor.rb
│ └── util.rb
│ └── stitching.rb
├── Gemfile
├── gemfiles
├── graphql_2.0.0.gemfile
├── graphql_2.1.0.gemfile
├── graphql_2.2.0.gemfile
├── graphql_2.3.0.gemfile
└── graphql_2.4.0.gemfile
├── Rakefile
├── test
├── graphql
│ ├── stitching
│ │ ├── composer
│ │ │ ├── validate_composition_test.rb
│ │ │ ├── merge_scalar_test.rb
│ │ │ ├── merge_input_object_test.rb
│ │ │ ├── merge_union_test.rb
│ │ │ ├── validate_interfaces_test.rb
│ │ │ ├── merge_object_test.rb
│ │ │ ├── configuration_test.rb
│ │ │ ├── merge_interface_test.rb
│ │ │ ├── merge_enum_test.rb
│ │ │ ├── merge_directive_test.rb
│ │ │ └── merge_root_objects_test.rb
│ │ ├── integration
│ │ │ ├── multiple_keys_test.rb
│ │ │ ├── mutations_test.rb
│ │ │ ├── shareables_test.rb
│ │ │ ├── unions_test.rb
│ │ │ ├── merged_child.rb
│ │ │ ├── nested_root_test.rb
│ │ │ ├── subscriptions_test.rb
│ │ │ ├── arguments_test.rb
│ │ │ ├── introspection_test.rb
│ │ │ ├── skip_include_test.rb
│ │ │ ├── errors_test.rb
│ │ │ ├── conditionals_test.rb
│ │ │ ├── visibility_test.rb
│ │ │ └── composite_keys_test.rb
│ │ ├── executor
│ │ │ ├── root_source_test.rb
│ │ │ └── executor_test.rb
│ │ ├── plan_test.rb
│ │ ├── federation_test.rb
│ │ ├── planner
│ │ │ ├── plan_introspection_test.rb
│ │ │ └── plan_delegations_test.rb
│ │ ├── request
│ │ │ └── skip_include_test.rb
│ │ ├── http_executable_test.rb
│ │ └── supergraph
│ │ │ └── from_definition_test.rb
│ └── stitching_test.rb
├── schemas
│ ├── subscriptions.rb
│ ├── nested_root.rb
│ ├── merged_child.rb
│ ├── shareables.rb
│ ├── visibility.rb
│ ├── mutations.rb
│ ├── multiple_keys.rb
│ ├── errors.rb
│ └── conditionals.rb
└── test_helper_test.rb
├── LICENSE
├── .github
└── workflows
│ └── ci.yml
├── graphql-stitching.gemspec
└── .gitignore
/examples/file_uploads/file.txt:
--------------------------------------------------------------------------------
1 | Hello World!
--------------------------------------------------------------------------------
/examples/subscriptions/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/subscriptions/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/subscriptions/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/master.key:
--------------------------------------------------------------------------------
1 | b7cc7c63ca7597ff3f5fc4a6cb27b65d
--------------------------------------------------------------------------------
/docs/images/library.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gmac/graphql-stitching-ruby/HEAD/docs/images/library.png
--------------------------------------------------------------------------------
/docs/images/merging.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gmac/graphql-stitching-ruby/HEAD/docs/images/merging.png
--------------------------------------------------------------------------------
/docs/images/stitching.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gmac/graphql-stitching-ruby/HEAD/docs/images/stitching.png
--------------------------------------------------------------------------------
/examples/file_uploads/Procfile:
--------------------------------------------------------------------------------
1 | gateway: bundle exec ruby gateway.rb
2 | remote: bundle exec ruby remote.rb
3 |
--------------------------------------------------------------------------------
/examples/subscriptions/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require_relative "../config/boot"
3 | require "rake"
4 | Rake.application.run
5 |
--------------------------------------------------------------------------------
/examples/subscriptions/bin/importmap:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require_relative "../config/application"
4 | require "importmap/commands"
5 |
--------------------------------------------------------------------------------
/examples/subscriptions/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 |
--------------------------------------------------------------------------------
/.yardopts:
--------------------------------------------------------------------------------
1 | --no-private
2 | --markup=markdown
3 | --readme=readme.md
4 | --title='GraphQL Stitching Ruby API Documentation'
5 | 'lib/**/*.rb' - '*.md'
6 |
--------------------------------------------------------------------------------
/examples/merged_types/Procfile:
--------------------------------------------------------------------------------
1 | gateway: bundle exec ruby gateway.rb
2 | remote1: bundle exec ruby remote1.rb
3 | remote2: bundle exec ruby remote2.rb
4 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL
4 | module Stitching
5 | VERSION = "1.7.3"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/examples/merged_types/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'rack'
6 | gem 'rackup'
7 | gem 'foreman'
8 | gem 'graphql'
9 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 | gemspec
5 |
6 | gem 'pry'
7 | gem 'pry-byebug'
8 | gem 'warning'
9 | gem 'minitest-stub-const'
10 |
--------------------------------------------------------------------------------
/examples/subscriptions/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path("../config/application", __dir__)
3 | require_relative "../config/boot"
4 | require "rails/commands"
5 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative "application"
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/examples/subscriptions/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "config/environment"
4 |
5 | run Rails.application
6 | Rails.application.load_server
7 |
--------------------------------------------------------------------------------
/examples/file_uploads/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'rack'
6 | gem 'rackup'
7 | gem 'foreman'
8 | gem 'graphql'
9 | gem 'apollo_upload_server', '2.1'
10 |
--------------------------------------------------------------------------------
/gemfiles/graphql_2.0.0.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'graphql', '~> 2.0.0'
6 | gem 'warning'
7 | gem 'minitest-stub-const'
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/graphql_2.1.0.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'graphql', '~> 2.1.0'
6 | gem 'warning'
7 | gem 'minitest-stub-const'
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/graphql_2.2.0.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'graphql', '~> 2.2.0'
6 | gem 'warning'
7 | gem 'minitest-stub-const'
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/graphql_2.3.0.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'graphql', '~> 2.3.0'
6 | gem 'warning'
7 | gem 'minitest-stub-const'
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/graphql_2.4.0.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'graphql', '~> 2.4.0'
6 | gem 'warning'
7 | gem 'minitest-stub-const'
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/boot.rb:
--------------------------------------------------------------------------------
1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
2 |
3 | require "bundler/setup" # Set up gems listed in the Gemfile.
4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations.
5 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | mount ActionCable.server, at: "/cable"
3 |
4 | post "/graphql", to: "graphql#execute"
5 | get "/graphql/event", to: "graphql#event"
6 |
7 | root "graphql#client"
8 | end
9 |
--------------------------------------------------------------------------------
/examples/subscriptions/bin/docker-entrypoint:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | # If running the rails server then create or migrate existing database
4 | if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
5 | ./bin/rails db:prepare
6 | fi
7 |
8 | exec "${@}"
9 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rake/testtask'
4 |
5 | Rake::TestTask.new(:test) do |t, args|
6 | puts args
7 | t.libs << "test"
8 | t.libs << "lib"
9 | t.test_files = FileList['test/**/*_test.rb']
10 | end
11 |
12 | task :default => :test
--------------------------------------------------------------------------------
/examples/subscriptions/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: test
6 |
7 | production:
8 | adapter: redis
9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
10 | channel_prefix: subscriptions_production
11 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/composer/base_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Composer
5 | class BaseValidator
6 | def perform(ctx, composer)
7 | raise "not implemented"
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/examples/subscriptions/app/graphql/stitched_schema.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../../../lib/graphql/stitching"
2 |
3 | StitchedSchema = GraphQL::Stitching::Client.new(locations: {
4 | entities: {
5 | schema: EntitiesSchema,
6 | },
7 | subscriptions: {
8 | schema: SubscriptionsSchema,
9 | },
10 | })
11 |
--------------------------------------------------------------------------------
/examples/subscriptions/.gitattributes:
--------------------------------------------------------------------------------
1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files.
2 |
3 | # Mark the database schema as having been generated.
4 | db/schema.rb linguist-generated
5 |
6 | # Mark any vendored files as having been vendored.
7 | vendor/* linguist-vendored
8 | config/credentials/*.yml.enc diff=rails_credentials
9 | config/credentials.yml.enc diff=rails_credentials
10 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative "boot"
2 |
3 | require "rails/all"
4 |
5 | # Require the gems listed in Gemfile, including any gems
6 | # you've limited to :test, :development, or :production.
7 | Bundler.require(*Rails.groups)
8 |
9 | module Subscriptions
10 | class Application < Rails::Application
11 | # Initialize configuration defaults for originally generated Rails version.
12 | config.load_defaults 7.1
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/credentials.yml.enc:
--------------------------------------------------------------------------------
1 | kc7oqupA92PHWoLMp0Rd/b52T1bVMbrGyLfTivb0hv6JbXoYtLQz60ybA3O4moEiU/zJa1kcz/9T2MZ0iOp/ah8vu864iljomLt8saNZrUfcTSDQvxyAYpbbWyyV0F0twf9TIbmWrSZep2usSMY5O5Tck4AAlv7Kld2+Fe7aAFtMLZCFclgOYvg+c3JyO0fW2UqYVRffWI6brTW+BCY6DShx7O4rYXLRUg831f5T3Ujz/c2tUUHI6V9Q/WvUbI4TZ3JwYh+cMF0ARNcWVcbHBcK4WYcKAlz8FdKPWp/CDhDJJt5dBbhCf/b1Hh/74qeLDR8zYCq6sPkdCvcYmL8ELjCoaNYh7RhV8e0SrJKpe8FyGck0Zgl0noteTu3yCAw42731BL88wagjcIe/B3SRLSLyvtya--xBs9lXK9DaXe8cVf--gj2alHD2yabXnsT+PJ9KMg==
--------------------------------------------------------------------------------
/examples/subscriptions/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
7 |
8 |
9 |
10 |
11 |
We're sorry, but something went wrong.
12 |
13 |
If you are the application owner check the logs for more information.
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
4 | # Use this to limit dissemination of sensitive information.
5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
6 | Rails.application.config.filter_parameters += [
7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
8 | ]
9 |
--------------------------------------------------------------------------------
/examples/subscriptions/db/seeds.rb:
--------------------------------------------------------------------------------
1 | # This file should ensure the existence of records required to run the application in every environment (production,
2 | # development, test). The code here should be idempotent so that it can be executed at any point in every environment.
3 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
4 | #
5 | # Example:
6 | #
7 | # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
8 | # MovieGenre.find_or_create_by!(name: genre_name)
9 | # end
10 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/validate_composition_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, general concerns' do
6 | def test_errors_for_merged_types_of_different_kinds
7 | a = "type Query { a:Boom } type Boom { a:String }"
8 | b = "type Query { b:Boom } interface Boom { b:String }"
9 |
10 | assert_error('Cannot merge different kinds for `Boom`. Found: OBJECT, INTERFACE', CompositionError) do
11 | compose_definitions({ "a" => a, "b" => b })
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/examples/subscriptions/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
7 |
8 |
9 |
10 |
11 |
The change you wanted was rejected.
12 |
Maybe you tried to change something you didn't have access to.
13 |
14 |
If you are the application owner check the logs for more information.
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/initializers/permissions_policy.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Define an application-wide HTTP permissions policy. For further
4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy
5 |
6 | # Rails.application.config.permissions_policy do |policy|
7 | # policy.camera :none
8 | # policy.gyroscope :none
9 | # policy.microphone :none
10 | # policy.usb :none
11 | # policy.fullscreen :self
12 | # policy.payment :self, "https://secure.example.com"
13 | # end
14 |
--------------------------------------------------------------------------------
/examples/subscriptions/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
7 |
8 |
9 |
10 |
11 |
The page you were looking for doesn't exist.
12 |
You may have mistyped the address or the page may have moved.
13 |
14 |
If you are the application owner check the logs for more information.
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/examples/file_uploads/remote.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rackup'
4 | require 'json'
5 | require 'graphql'
6 | require_relative './helpers'
7 |
8 | class RemoteApp
9 | def call(env)
10 | params = apollo_upload_server_middleware_params(env)
11 | result = RemoteSchema.execute(
12 | query: params["query"],
13 | variables: params["variables"],
14 | operation_name: params["operationName"],
15 | )
16 |
17 | [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
18 | end
19 | end
20 |
21 | Rackup::Handler.default.run(RemoteApp.new, :Port => 3001)
22 |
--------------------------------------------------------------------------------
/examples/merged_types/remote1.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rackup'
4 | require 'json'
5 | require 'graphql'
6 | require_relative '../../test/schemas/example'
7 |
8 | class FirstRemoteApp
9 | def call(env)
10 | req = Rack::Request.new(env)
11 | params = JSON.parse(req.body.read)
12 | result = Schemas::Example::Storefronts.execute(
13 | query: params["query"],
14 | variables: params["variables"],
15 | operation_name: params["operationName"],
16 | )
17 |
18 | [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
19 | end
20 | end
21 |
22 | Rackup::Handler.default.run(FirstRemoteApp.new, :Port => 3001)
23 |
--------------------------------------------------------------------------------
/examples/merged_types/remote2.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rackup'
4 | require 'json'
5 | require 'graphql'
6 | require_relative '../../test/schemas/example'
7 |
8 | class SecondRemoteApp
9 | def call(env)
10 | req = Rack::Request.new(env)
11 | params = JSON.parse(req.body.read)
12 | result = Schemas::Example::Manufacturers.execute(
13 | query: params["query"],
14 | variables: params["variables"],
15 | operation_name: params["operationName"],
16 | )
17 |
18 | [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
19 | end
20 | end
21 |
22 | Rackup::Handler.default.run(SecondRemoteApp.new, :Port => 3002)
23 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, "\\1en"
8 | # inflect.singular /^(ox)en/i, "\\1"
9 | # inflect.irregular "person", "people"
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym "RESTful"
16 | # end
17 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite. Versions 3.8.0 and up are supported.
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem "sqlite3"
6 | #
7 | default: &default
8 | adapter: sqlite3
9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10 | timeout: 5000
11 |
12 | development:
13 | <<: *default
14 | database: storage/development.sqlite3
15 |
16 | # Warning: The database defined as "test" will be erased and
17 | # re-generated from your development database when you run "rake".
18 | # Do not set this db to the same as development or production.
19 | test:
20 | <<: *default
21 | database: storage/test.sqlite3
22 |
23 | production:
24 | <<: *default
25 | database: storage/production.sqlite3
26 |
--------------------------------------------------------------------------------
/examples/merged_types/README.md:
--------------------------------------------------------------------------------
1 | # Merged types example
2 |
3 | This example demonstrates several stitched schemas running across small Rack servers with types merged across locations. The main "gateway" location stitches its local schema onto two remote endpoints.
4 |
5 | Try running it:
6 |
7 | ```shell
8 | cd examples/merged_types
9 | bundle install
10 | foreman start
11 | ```
12 |
13 | Then visit the gateway service at [`http://localhost:3000`](http://localhost:3000) and try this query:
14 |
15 | ```graphql
16 | query {
17 | storefront(id: "1") {
18 | id
19 | products {
20 | upc
21 | name
22 | price
23 | manufacturer {
24 | name
25 | address
26 | products { upc name }
27 | }
28 | }
29 | }
30 | }
31 | ```
32 |
33 | The above query collects data from all locations. You can also request introspections that resolve using the combined supergraph schema.
34 |
--------------------------------------------------------------------------------
/examples/subscriptions/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2 | #
3 | # If you find yourself ignoring temporary files generated by your text editor
4 | # or operating system, you probably want to add a global ignore instead:
5 | # git config --global core.excludesfile '~/.gitignore_global'
6 |
7 | # Ignore bundler config.
8 | /.bundle
9 |
10 | # Ignore all environment files (except templates).
11 | /.env*
12 | !/.env*.erb
13 |
14 | # Ignore all logfiles and tempfiles.
15 | /log/*
16 | /tmp/*
17 | !/log/.keep
18 | !/tmp/.keep
19 |
20 | # Ignore pidfiles, but keep the directory.
21 | /tmp/pids/*
22 | !/tmp/pids/
23 | !/tmp/pids/.keep
24 |
25 | # Ignore storage (uploaded files in development and any SQLite databases).
26 | /storage/*
27 | !/storage/.keep
28 | /tmp/storage/*
29 | !/tmp/storage/
30 | !/tmp/storage/.keep
31 |
32 | /public/assets
33 |
34 | # Ignore master key for decrypting credentials and more.
35 | /config/master.key
36 |
--------------------------------------------------------------------------------
/examples/subscriptions/app/models/repository.rb:
--------------------------------------------------------------------------------
1 | class Repository
2 | POSTS = {}
3 | COMMENTS = {}
4 |
5 | class << self
6 | def post(id)
7 | POSTS.fetch(id)
8 | end
9 |
10 | def comment(id)
11 | COMMENTS.fetch(id)
12 | end
13 |
14 | def add_post(title, id = Time.zone.now.to_i.to_s)
15 | post = {
16 | id: id,
17 | title: title,
18 | comments: [],
19 | }
20 | POSTS[post[:id]] = post
21 | post
22 | end
23 |
24 | def add_comment(post_id, message)
25 | comment = {
26 | id: Time.zone.now.to_i.to_s,
27 | message: message,
28 | }
29 | parent = post(post_id)
30 | parent[:comments] << comment
31 | COMMENTS[comment[:id]] = comment
32 |
33 | SubscriptionsSchema.subscriptions.trigger(:comment_added_to_post, { post_id: parent[:id] }, comment)
34 | comment
35 | end
36 | end
37 | end
38 |
39 | Repository.add_post("How to walk, talk, and chew gum", "1")
40 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization and
2 | # are automatically loaded by Rails. If you want to use locales other than
3 | # English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t "hello"
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t("hello") %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # To learn more about the API, please read the Rails Internationalization guide
20 | # at https://guides.rubyonrails.org/i18n.html.
21 | #
22 | # Be aware that YAML interprets the following case-insensitive strings as
23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
24 | # must be quoted to be interpreted as strings. For example:
25 | #
26 | # en:
27 | # "yes": yup
28 | # enabled: "ON"
29 |
30 | en:
31 | hello: "Hello world"
32 |
--------------------------------------------------------------------------------
/examples/file_uploads/gateway.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rackup'
4 | require 'json'
5 | require 'graphql'
6 | require_relative '../../lib/graphql/stitching'
7 | require_relative './helpers'
8 |
9 | class StitchedApp
10 | def initialize
11 | @client = GraphQL::Stitching::Client.new(locations: {
12 | gateway: {
13 | schema: GatewaySchema,
14 | },
15 | remote: {
16 | schema: RemoteSchema,
17 | executable: GraphQL::Stitching::HttpExecutable.new(
18 | url: "http://localhost:3001",
19 | upload_types: ["Upload"]
20 | ),
21 | },
22 | })
23 | end
24 |
25 | def call(env)
26 | params = apollo_upload_server_middleware_params(env)
27 | result = @client.execute(
28 | query: params["query"],
29 | variables: params["variables"],
30 | operation_name: params["operationName"],
31 | )
32 |
33 | [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
34 | end
35 | end
36 |
37 | Rackup::Handler.default.run(StitchedApp.new, :Port => 3000)
38 |
--------------------------------------------------------------------------------
/examples/subscriptions/app/controllers/graphql_controller.rb:
--------------------------------------------------------------------------------
1 | class GraphqlController < ActionController::Base
2 | skip_before_action :verify_authenticity_token
3 | layout false
4 |
5 | def client
6 | end
7 |
8 | def execute
9 | result = StitchedSchema.execute(
10 | params[:query],
11 | variables: ensure_hash(params[:variables]),
12 | context: {},
13 | operation_name: params[:operationName],
14 | )
15 |
16 | render json: result
17 | end
18 |
19 | COMMENTS = ["Great", "Meh", "Terrible"].freeze
20 |
21 | def event
22 | comment = Repository.add_comment("1", COMMENTS.sample)
23 | render json: comment
24 | end
25 |
26 | private
27 |
28 | def ensure_hash(ambiguous_param)
29 | case ambiguous_param
30 | when String
31 | if ambiguous_param.present?
32 | ensure_hash(JSON.parse(ambiguous_param))
33 | else
34 | {}
35 | end
36 | when Hash, ActionController::Parameters
37 | ambiguous_param
38 | when nil
39 | {}
40 | else
41 | raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/examples/subscriptions/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "fileutils"
3 |
4 | # path to your application root.
5 | APP_ROOT = File.expand_path("..", __dir__)
6 |
7 | def system!(*args)
8 | system(*args, exception: true)
9 | end
10 |
11 | FileUtils.chdir APP_ROOT do
12 | # This script is a way to set up or update your development environment automatically.
13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome.
14 | # Add necessary setup steps to this file.
15 |
16 | puts "== Installing dependencies =="
17 | system! "gem install bundler --conservative"
18 | system("bundle check") || system!("bundle install")
19 |
20 | # puts "\n== Copying sample files =="
21 | # unless File.exist?("config/database.yml")
22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml"
23 | # end
24 |
25 | puts "\n== Preparing database =="
26 | system! "bin/rails db:prepare"
27 |
28 | puts "\n== Removing old logs and tempfiles =="
29 | system! "bin/rails log:clear tmp:clear"
30 |
31 | puts "\n== Restarting application server =="
32 | system! "bin/rails restart"
33 | end
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Greg MacWilliam
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/schemas/subscriptions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module Subscriptions
5 | class SubscriptionSchema < GraphQL::Schema
6 | class Product < GraphQL::Schema::Object
7 | field :upc, ID, null: false
8 | end
9 |
10 | class Manufacturer < GraphQL::Schema::Object
11 | field :id, ID, null: false
12 | end
13 |
14 | class UpdateToProduct < GraphQL::Schema::Subscription
15 | argument :upc, ID, required: true
16 | field :product, Product, null: false
17 | field :manufacturer, Manufacturer, null: true
18 |
19 | def subscribe(upc:)
20 | { product: { upc: upc }, manufacturer: nil }
21 | end
22 |
23 | def update(upc:)
24 | { product: { upc: upc }, manufacturer: object }
25 | end
26 | end
27 |
28 | class SubscriptionType < GraphQL::Schema::Object
29 | field :update_to_product, subscription: UpdateToProduct
30 | end
31 |
32 | class QueryType < GraphQL::Schema::Object
33 | field :ping, String
34 | end
35 |
36 | query QueryType
37 | subscription SubscriptionType
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Define an application-wide content security policy.
4 | # See the Securing Rails Applications Guide for more information:
5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header
6 |
7 | # Rails.application.configure do
8 | # config.content_security_policy do |policy|
9 | # policy.default_src :self, :https
10 | # policy.font_src :self, :https, :data
11 | # policy.img_src :self, :https, :data
12 | # policy.object_src :none
13 | # policy.script_src :self, :https
14 | # policy.style_src :self, :https
15 | # # Specify URI for violation reports
16 | # # policy.report_uri "/csp-violation-report-endpoint"
17 | # end
18 | #
19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles.
20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
21 | # config.content_security_policy_nonce_directives = %w(script-src style-src)
22 | #
23 | # # Report violations without enforcing the policy.
24 | # # config.content_security_policy_report_only = true
25 | # end
26 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/multiple_keys_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/multiple_keys"
5 |
6 | describe 'GraphQL::Stitching, multiple keys' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "storefronts" => Schemas::MultipleKeys::Storefronts,
10 | "products" => Schemas::MultipleKeys::Products,
11 | "catelogs" => Schemas::MultipleKeys::Catelogs,
12 | })
13 | end
14 |
15 | def test_queries_through_multiple_keys_from_outer_edge
16 | # storefronts > products > catelogs
17 | result = plan_and_execute(@supergraph, "{ result: storefrontsProductById(id: \"1\") { location edition } }")
18 |
19 | assert_equal "Toronto", result.dig("data", "result", "location")
20 | assert_equal "Spring", result.dig("data", "result", "edition")
21 | end
22 |
23 | def test_queries_through_multiple_keys_from_center
24 | # storefronts < products > catelogs
25 | result = plan_and_execute(@supergraph, "{ result: productsProductById(id: \"1\") { location edition } }")
26 |
27 | assert_equal "Toronto", result.dig("data", "result", "location")
28 | assert_equal "Spring", result.dig("data", "result", "edition")
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/examples/subscriptions/app/graphql/entities_schema.rb:
--------------------------------------------------------------------------------
1 | class EntitiesSchema < GraphQL::Schema
2 | class StitchingResolver < GraphQL::Schema::Directive
3 | graphql_name "stitch"
4 | locations FIELD_DEFINITION
5 | argument :key, String, required: true
6 | argument :arguments, String, required: false
7 | repeatable true
8 | end
9 |
10 | class Comment < GraphQL::Schema::Object
11 | field :id, ID, null: false
12 | field :message, String, null: false
13 | end
14 |
15 | class Post < GraphQL::Schema::Object
16 | field :id, ID, null: false
17 | field :title, String, null: false
18 | field :comments, [Comment, null: false], null: false
19 | end
20 |
21 | class QueryType < GraphQL::Schema::Object
22 | field :posts, [Post, null: true] do
23 | directive StitchingResolver, key: "id"
24 | argument :ids, [ID], required: true
25 | end
26 |
27 | def posts(ids:)
28 | ids.map { Repository.post(_1) }
29 | end
30 |
31 | field :comments, [Comment, null: true] do
32 | directive StitchingResolver, key: "id"
33 | argument :ids, [ID], required: true
34 | end
35 |
36 | def comments(ids:)
37 | ids.map { Repository.comment(_1) }
38 | end
39 | end
40 |
41 | query QueryType
42 | end
43 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | include:
15 | - gemfile: Gemfile
16 | ruby: 3.3
17 | - gemfile: gemfiles/graphql_2.4.0.gemfile
18 | ruby: 3.2
19 | - gemfile: gemfiles/graphql_2.3.0.gemfile
20 | ruby: 3.2
21 | - gemfile: gemfiles/graphql_2.2.0.gemfile
22 | ruby: 3.1
23 | - gemfile: gemfiles/graphql_2.1.0.gemfile
24 | ruby: 3.1
25 | - gemfile: gemfiles/graphql_2.0.0.gemfile
26 | ruby: 3.1
27 | - gemfile: gemfiles/graphql_2.0.0.gemfile
28 | ruby: 2.7
29 | steps:
30 | - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV
31 | - uses: actions/checkout@v4
32 | - name: Setup Ruby
33 | uses: ruby/setup-ruby@v1
34 | with:
35 | ruby-version: ${{ matrix.ruby }}
36 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically
37 | - name: Run tests
38 | run: |
39 | gem install bundler -v 2.4.22
40 | bundle install --jobs 4 --retry 3
41 | bundle exec rake test
42 |
--------------------------------------------------------------------------------
/examples/subscriptions/README.md:
--------------------------------------------------------------------------------
1 | # Subscriptions example
2 |
3 | This example demonstrates stitching subscriptions in a small Rails application. No database required, just bundle-install and try running it:
4 |
5 | ```shell
6 | cd examples/subscriptions
7 | bundle install
8 | bin/rails s
9 | ```
10 |
11 | Then visit the GraphiQL client running at [`http://localhost:3000`](http://localhost:3000) and try subscribing:
12 |
13 | ```graphql
14 | subscription SubscribeToComments {
15 | commentAddedToPost(postId: "1") {
16 | post {
17 | id
18 | title
19 | comments {
20 | id
21 | message
22 | }
23 | }
24 | comment {
25 | id
26 | message
27 | }
28 | }
29 | }
30 | ```
31 |
32 | Upon running that subscription, you'll recieve an initial payload for the subscribe event that stitches post data from another schema. Now try triggering events by hitting this URL in another browser window:
33 |
34 | ```
35 | http://localhost:3000/graphql/event
36 | ```
37 |
38 | Each refresh of the above URL will add a comment and trigger a subscription event. Assuming you're subscribed, you should see comment activity appear in the GraphiQL output. Again, these update events are stitched to enrich the basic subscription payload with additional data from another schema.
39 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_scalar_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging scalars' do
6 |
7 | def test_merges_scalar_descriptions
8 | a = %{"""a""" scalar URL type Query { url:URL }}
9 | b = %{"""b""" scalar URL type Query { url:URL }}
10 |
11 | info = compose_definitions({ "a" => a, "b" => b }, {
12 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
13 | })
14 |
15 | assert_equal "a/b", info.schema.get_type("URL").description
16 | end
17 |
18 | def test_merges_scalar_directives
19 | a = <<~GRAPHQL
20 | directive @fizzbuzz(arg: String!) on SCALAR
21 | scalar Thing @fizzbuzz(arg: "a")
22 | type Query { thing:Thing }
23 | GRAPHQL
24 |
25 | b = <<~GRAPHQL
26 | directive @fizzbuzz(arg: String!) on SCALAR
27 | scalar Thing @fizzbuzz(arg: "b")
28 | type Query { thing:Thing }
29 | GRAPHQL
30 |
31 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
32 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
33 | })
34 |
35 | assert_equal "a/b", supergraph.schema.get_type("Thing").directives.first.arguments.keyword_arguments[:arg]
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/examples/subscriptions/app/channels/graphql_channel.rb:
--------------------------------------------------------------------------------
1 | class GraphqlChannel < ActionCable::Channel::Base
2 | def subscribed
3 | @subscription_ids = []
4 | end
5 |
6 | def execute(data)
7 | result = StitchedSchema.execute(
8 | data["query"],
9 | context: { channel: self },
10 | variables: ensure_hash(data["variables"]),
11 | operation_name: data["operationName"],
12 | )
13 |
14 | payload = {
15 | result: result.to_h,
16 | more: result.subscription?,
17 | }
18 |
19 | if result.context[:subscription_id]
20 | @subscription_ids << result.context[:subscription_id]
21 | end
22 |
23 | transmit(payload)
24 | end
25 |
26 | def unsubscribed
27 | @subscription_ids.each { |sid|
28 | SubscriptionsSchema.subscriptions.delete_subscription(sid)
29 | }
30 | end
31 |
32 | private
33 |
34 | def ensure_hash(ambiguous_param)
35 | case ambiguous_param
36 | when String
37 | if ambiguous_param.present?
38 | ensure_hash(JSON.parse(ambiguous_param))
39 | else
40 | {}
41 | end
42 | when Hash, ActionController::Parameters
43 | ambiguous_param
44 | when nil
45 | {}
46 | else
47 | raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket-<%= Rails.env %>
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket-<%= Rails.env %>
23 |
24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name-<%= Rails.env %>
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/graphql-stitching.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'graphql/stitching/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = 'graphql-stitching'
8 | spec.version = GraphQL::Stitching::VERSION
9 | spec.authors = ['Greg MacWilliam']
10 | spec.summary = 'GraphQL schema stitching for Ruby'
11 | spec.description = 'Combine GraphQL services into one unified graph'
12 | spec.homepage = 'https://github.com/gmac/graphql-stitching-ruby'
13 | spec.license = 'MIT'
14 |
15 | spec.required_ruby_version = '>= 2.7.0'
16 |
17 | spec.metadata = {
18 | 'homepage_uri' => 'https://github.com/gmac/graphql-stitching-ruby',
19 | 'changelog_uri' => 'https://github.com/gmac/graphql-stitching-ruby/releases',
20 | 'source_code_uri' => 'https://github.com/gmac/graphql-stitching-ruby',
21 | 'bug_tracker_uri' => 'https://github.com/gmac/graphql-stitching-ruby/issues',
22 | }
23 |
24 | spec.files = `git ls-files -z`.split("\x0").reject do |f|
25 | f.match(%r{^test/})
26 | end
27 | spec.require_paths = ['lib']
28 |
29 | spec.add_runtime_dependency 'graphql', '>= 2.0'
30 |
31 | spec.add_development_dependency 'bundler', '~> 2.0'
32 | spec.add_development_dependency 'rake', '~> 12.0'
33 | spec.add_development_dependency 'minitest', '~> 5.12'
34 | end
35 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/mutations_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/mutations"
5 |
6 | describe 'GraphQL::Stitching, mutations' do
7 | def setup
8 | Schemas::Mutations.reset
9 |
10 | @supergraph = compose_definitions({
11 | "a" => Schemas::Mutations::MutationsA,
12 | "b" => Schemas::Mutations::MutationsB,
13 | })
14 | end
15 |
16 | def test_mutates_serially_and_stitches_results
17 | mutations = %|
18 | mutation AddRecords {
19 | first: addViaA { id via a b }
20 | second: addViaB { id via a b }
21 | third: addViaB { id via a b }
22 | fourth: addViaA { id via a b }
23 | fifth: addViaA { id via a b }
24 | }
25 | |
26 |
27 | result = plan_and_execute(@supergraph, mutations)
28 |
29 | expected = {
30 | "data" => {
31 | "first" => { "id" => "1", "via" => "A", "a" => "A1", "b" => "B1" },
32 | "second" => { "id" => "2", "via" => "B", "a" => "A2", "b" => "B2" },
33 | "third" => { "id" => "3", "via" => "B", "a" => "A3", "b" => "B3" },
34 | "fourth" => { "id" => "4", "via" => "A", "a" => "A4", "b" => "B4" },
35 | "fifth" => { "id" => "5", "via" => "A", "a" => "A5", "b" => "B5" },
36 | },
37 | }
38 |
39 | assert_equal expected, result
40 | assert_equal ["1", "2", "3", "4", "5"], Schemas::Mutations.creation_order
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/examples/subscriptions/app/graphql/subscriptions_schema.rb:
--------------------------------------------------------------------------------
1 | class SubscriptionsSchema < GraphQL::Schema
2 | class StitchedActionCableSubscriptions < GraphQL::Subscriptions::ActionCableSubscriptions
3 | def execute_update(subscription_id, event, object)
4 | result = super(subscription_id, event, object)
5 | result.context[:stitch_subscription_update]&.call(result)
6 | result
7 | end
8 | end
9 |
10 | class Post < GraphQL::Schema::Object
11 | field :id, ID, null: false
12 | end
13 |
14 | class Comment < GraphQL::Schema::Object
15 | field :id, ID, null: false
16 | end
17 |
18 | class CommentAddedToPost < GraphQL::Schema::Subscription
19 | argument :post_id, ID, required: true
20 | field :post, Post, null: false
21 | field :comment, Comment, null: true
22 |
23 | def subscribe(post_id:)
24 | {
25 | post: { id: post_id },
26 | comment: nil,
27 | }
28 | end
29 |
30 | def update(post_id:)
31 | {
32 | post: { id: post_id },
33 | comment: object,
34 | }
35 | end
36 | end
37 |
38 | class SubscriptionType < GraphQL::Schema::Object
39 | field :comment_added_to_post, subscription: CommentAddedToPost
40 | end
41 |
42 | class QueryType < GraphQL::Schema::Object
43 | field :ping, String
44 |
45 | def ping
46 | "PONG"
47 | end
48 | end
49 |
50 | use StitchedActionCableSubscriptions
51 |
52 | subscription SubscriptionType
53 | query QueryType
54 | end
55 |
--------------------------------------------------------------------------------
/examples/merged_types/gateway.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rackup'
4 | require 'json'
5 | require 'graphql'
6 | require_relative '../../lib/graphql/stitching'
7 | require_relative '../../test/schemas/example'
8 |
9 | class StitchedApp
10 | def initialize
11 | file = File.open("#{__dir__}/graphiql.html")
12 | @graphiql = file.read
13 | file.close
14 |
15 | @client = GraphQL::Stitching::Client.new(locations: {
16 | products: {
17 | schema: Schemas::Example::Products,
18 | },
19 | storefronts: {
20 | schema: Schemas::Example::Storefronts,
21 | executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
22 | },
23 | manufacturers: {
24 | schema: Schemas::Example::Manufacturers,
25 | executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
26 | }
27 | })
28 | end
29 |
30 | def call(env)
31 | req = Rack::Request.new(env)
32 | case req.path_info
33 | when /graphql/
34 | params = JSON.parse(req.body.read)
35 |
36 | result = @client.execute(
37 | query: params["query"],
38 | variables: params["variables"],
39 | operation_name: params["operationName"],
40 | )
41 |
42 | [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
43 | else
44 | [200, {"content-type" => "text/html"}, [@graphiql]]
45 | end
46 | end
47 | end
48 |
49 | Rackup::Handler.default.run(StitchedApp.new, :Port => 3000)
50 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/shareables_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/shareables"
5 |
6 | describe 'GraphQL::Stitching, shareables' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "a" => Schemas::Shareables::ShareableA,
10 | "b" => Schemas::Shareables::ShareableB,
11 | })
12 | end
13 |
14 | def test_mutates_serially_and_stitches_results
15 | query = %|
16 | query {
17 | gadgetA(id: "1") {
18 | id
19 | name
20 | gizmo { a b c }
21 | uniqueToA
22 | uniqueToB
23 | }
24 | gadgetB(id: "1") {
25 | id
26 | name
27 | gizmo { a b c }
28 | uniqueToA
29 | uniqueToB
30 | }
31 | }
32 | |
33 |
34 | result = plan_and_execute(@supergraph, query)
35 |
36 | expected = {
37 | "data" => {
38 | "gadgetA" => {
39 | "id" => "1",
40 | "name" => "A1",
41 | "gizmo" => { "a" => "apple", "b" => "banana", "c" => "coconut" },
42 | "uniqueToA" => "AA",
43 | "uniqueToB" => "BB",
44 | },
45 | "gadgetB" => {
46 | "id" => "1",
47 | "name" => "B1",
48 | "gizmo" => { "a" => "aardvark", "b" => "bat", "c" => "cat" },
49 | "uniqueToA" => "AA",
50 | "uniqueToB" => "BB",
51 | },
52 | },
53 | }
54 |
55 | assert_equal expected, result
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/test/schemas/nested_root.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module NestedRoot
5 | class Alpha < GraphQL::Schema
6 | class Query < GraphQL::Schema::Object
7 | field :apple, String, null: false
8 | field :error_a, String, null: false
9 |
10 | def apple
11 | "red"
12 | end
13 |
14 | def error_a
15 | raise GraphQL::ExecutionError.new("a")
16 | end
17 | end
18 |
19 | class Thing < GraphQL::Schema::Object
20 | field :query, Query, null: false
21 |
22 | def query
23 | {}
24 | end
25 | end
26 |
27 | class Mutation < GraphQL::Schema::Object
28 | field :do_stuff, Query, null: false
29 |
30 | def do_stuff
31 | {}
32 | end
33 |
34 | field :do_thing, Thing, null: false
35 |
36 | def do_thing
37 | {}
38 | end
39 |
40 | field :do_things, [Thing], null: false
41 |
42 | def do_things
43 | [{}, {}]
44 | end
45 | end
46 |
47 | query Query
48 | mutation Mutation
49 | end
50 |
51 | class Bravo < GraphQL::Schema
52 | class Query < GraphQL::Schema::Object
53 | field :banana, String, null: false
54 | field :error_b, String, null: false
55 |
56 | def banana
57 | "yellow"
58 | end
59 |
60 | def error_b
61 | raise GraphQL::ExecutionError.new("b")
62 | end
63 | end
64 |
65 | query Query
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/unions_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/unions"
5 |
6 | describe 'GraphQL::Stitching, unions' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "a" => Schemas::Unions::SchemaA,
10 | "b" => Schemas::Unions::SchemaB,
11 | "c" => Schemas::Unions::SchemaC,
12 | })
13 | end
14 |
15 | def test_plan_abstract_merged_via_concrete_resolvers
16 | query = %|
17 | {
18 | fruitsA(ids: ["1", "3"]) {
19 | ...on Apple { a b c }
20 | ...on Banana { a b }
21 | ...on Coconut { b c }
22 | }
23 | }
24 | |
25 |
26 | expected = {
27 | "fruitsA" => [
28 | { "a" => "a1", "b" => "b1", "c" => "c1" },
29 | { "a" => "a3", "b" => "b3" },
30 | ],
31 | }
32 |
33 | result = plan_and_execute(@supergraph, query)
34 | assert_equal expected, result["data"]
35 | end
36 |
37 | def test_plan_abstract_merged_types_via_abstract_resolver
38 | query = %|
39 | {
40 | fruitsC(ids: ["1", "4"]) {
41 | ...on Apple { a b c }
42 | ...on Banana { a b }
43 | ...on Coconut { b c }
44 | }
45 | }
46 | |
47 |
48 | expected = {
49 | "fruitsC" => [
50 | { "c" => "c1", "a" => "a1", "b" => "b1" },
51 | { "c" => "c4", "b" => "b4" },
52 | ],
53 | }
54 |
55 | result = plan_and_execute(@supergraph, query)
56 | assert_equal expected, result["data"]
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/examples/file_uploads/README.md:
--------------------------------------------------------------------------------
1 | # File uploads example
2 |
3 | This example demonstrates uploading files via the [GraphQL Upload spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
4 |
5 | Try running it:
6 |
7 | ```shell
8 | cd examples/file_uploads
9 | bundle install
10 | foreman start
11 | ```
12 |
13 | This example is headless, but you can verify the stitched schema is running by querying a field from each graph location:
14 |
15 | ```shell
16 | curl -X POST http://localhost:3000 \
17 | -H 'Content-Type: application/json' \
18 | -d '{"query":"{ gateway remote }"}'
19 | ```
20 |
21 | Now try submitting a multipart form upload with a file attachment, per the [spec](https://github.com/jaydenseric/graphql-multipart-request-spec?tab=readme-ov-file#curl-request). The response will echo the uploaded file contents:
22 |
23 | ```shell
24 | curl http://localhost:3000 \
25 | -H 'Content-Type: multipart/form-data' \
26 | -F operations='{ "query": "mutation ($file: Upload!) { gateway upload(file: $file) }", "variables": { "file": null } }' \
27 | -F map='{ "0": ["variables.file"] }' \
28 | -F 0=@file.txt
29 | ```
30 |
31 | This workflow has:
32 |
33 | 1. Submitted a multipart form to the stitched gateway.
34 | 2. The gateway server unpacked the request using [apollo_upload_server](https://github.com/jetruby/apollo_upload_server-ruby).
35 | 3. Stitching delegated the `upload` field to its appropraite subgraph location.
36 | 4. `HttpExecutable` has re-encoded the subgraph request into a multipart form.
37 | 5. The subgraph location has recieved, unpacked, and resolved the uploaded file.
--------------------------------------------------------------------------------
/examples/file_uploads/helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'action_dispatch'
4 | require 'apollo_upload_server/graphql_data_builder'
5 | require 'apollo_upload_server/upload'
6 |
7 | # ApolloUploadServer middleware only modifies Rails request params;
8 | # for simple Rack apps we need to extract the behavior.
9 | def apollo_upload_server_middleware_params(env)
10 | req = ActionDispatch::Request.new(env)
11 | if env['CONTENT_TYPE'].to_s.include?('multipart/form-data')
12 | ApolloUploadServer::GraphQLDataBuilder.new(strict_mode: true).call(req.params)
13 | else
14 | req.params
15 | end
16 | end
17 |
18 | # Gateway local schema
19 | class GatewaySchema < GraphQL::Schema
20 | class Query < GraphQL::Schema::Object
21 | field :gateway, Boolean, null: false
22 |
23 | def gateway
24 | true
25 | end
26 | end
27 |
28 | class Mutation < GraphQL::Schema::Object
29 | field :gateway, Boolean, null: false
30 |
31 | def gateway
32 | true
33 | end
34 | end
35 |
36 | query Query
37 | mutation Mutation
38 | end
39 |
40 | # Remote local schema, with file upload
41 | class RemoteSchema < GraphQL::Schema
42 | class Query < GraphQL::Schema::Object
43 | field :remote, Boolean, null: false
44 |
45 | def remote
46 | true
47 | end
48 | end
49 |
50 | class Mutation < GraphQL::Schema::Object
51 | field :upload, String, null: true do
52 | argument :file, ApolloUploadServer::Upload, required: true
53 | end
54 |
55 | def upload(file:)
56 | file.read
57 | end
58 | end
59 |
60 | query Query
61 | mutation Mutation
62 | end
63 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_input_object_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging input objects' do
6 |
7 | def test_merges_input_object_descriptions
8 | a = %{"""a""" input Test { field:String } type Query { get(test:Test):String }}
9 | b = %{"""b""" input Test { field:String } type Query { get(test:Test):String }}
10 |
11 | info = compose_definitions({ "a" => a, "b" => b }, {
12 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
13 | })
14 |
15 | assert_equal "a/b", info.schema.types["Test"].description
16 | end
17 |
18 | def test_merges_input_object_and_field_directives
19 | a = %|
20 | directive @fizzbuzz(arg: String!) on INPUT_OBJECT \| INPUT_FIELD_DEFINITION
21 | input Test @fizzbuzz(arg: "a") { field:String @fizzbuzz(arg: "a") }
22 | type Query { get(test:Test):String }
23 | |
24 |
25 | b = %|
26 | directive @fizzbuzz(arg: String!) on INPUT_OBJECT \| INPUT_FIELD_DEFINITION
27 | input Test @fizzbuzz(arg: "b") { field:String @fizzbuzz(arg: "b") }
28 | type Query { get(test:Test):String }
29 | |
30 |
31 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
32 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
33 | })
34 |
35 | assert_equal "a/b", supergraph.schema.types["Test"].directives.first.arguments.keyword_arguments[:arg]
36 | assert_equal "a/b", supergraph.schema.types["Test"].arguments["field"].directives.first.arguments.keyword_arguments[:arg]
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | /.config
4 | /coverage/
5 | /InstalledFiles
6 | /pkg/
7 | /spec/reports/
8 | /spec/examples.txt
9 | /test/tmp/
10 | /test/version_tmp/
11 | /tmp/
12 | .envrc
13 | Gemfile.lock
14 |
15 | # Used by dotenv library to load environment variables.
16 | # .env
17 | /example/env.json
18 |
19 | # Ignore Byebug command history file.
20 | .byebug_history
21 | .ruby-version
22 | .DS_Store
23 |
24 | examples/subscriptions/log/*
25 | examples/subscriptions/tmp/*
26 | examples/subscriptions/storage/*
27 |
28 | ## Specific to RubyMotion:
29 | .dat*
30 | .repl_history
31 | build/
32 | node_modules/
33 | package-lock.json
34 | *.bridgesupport
35 | build-iPhoneOS/
36 | build-iPhoneSimulator/
37 |
38 | ## Specific to RubyMotion (use of CocoaPods):
39 | #
40 | # We recommend against adding the Pods directory to your .gitignore. However
41 | # you should judge for yourself, the pros and cons are mentioned at:
42 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
43 | #
44 | # vendor/Pods/
45 |
46 | ## Documentation cache and generated files:
47 | /.yardoc/
48 | /_yardoc/
49 | /doc/
50 | /rdoc/
51 |
52 | ## Environment normalization:
53 | /.bundle/
54 | /vendor/bundle
55 | /lib/bundler/man/
56 |
57 | # for a library or gem, you might want to ignore these files since the code is
58 | # intended to run in multiple environments; otherwise, check them in:
59 | # Gemfile.lock
60 | # .ruby-version
61 | # .ruby-gemset
62 |
63 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
64 | .rvmrc
65 |
66 | # Used by RuboCop. Remote config files pulled in from inherit_from directive.
67 | # .rubocop-https?--*
68 |
--------------------------------------------------------------------------------
/test/graphql/stitching/executor/root_source_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::Executor, RootSource" do
6 | def setup
7 | @op = GraphQL::Stitching::Plan::Op.new(
8 | step: 1,
9 | after: 0,
10 | location: "products",
11 | operation_type: "query",
12 | path: [],
13 | if_type: "Storefront",
14 | selections: "{ storefront(id:$id) { products { _export_id: id } } }",
15 | variables: { "id" => "ID!" },
16 | resolver: nil
17 | )
18 |
19 | @source = GraphQL::Stitching::Executor::RootSource.new({}, "a")
20 | end
21 |
22 | def test_builds_document_for_an_operation
23 | source_document = @source.build_document(@op)
24 |
25 | expected = %|
26 | query($id:ID!){
27 | storefront(id:$id) { products { _export_id: id } }
28 | }
29 | |
30 |
31 | assert_equal squish_string(expected), source_document
32 | end
33 |
34 | def test_builds_document_with_operation_name
35 | source_document = @source.build_document(@op, "MyOperation")
36 |
37 | expected = %|
38 | query MyOperation_1($id:ID!){
39 | storefront(id:$id) { products { _export_id: id } }
40 | }
41 | |
42 |
43 | assert_equal squish_string(expected), source_document
44 | end
45 |
46 | def test_builds_document_with_operation_directives
47 | source_document = @source.build_document(@op, "MyOperation", %|@inContext(lang: "EN")|)
48 |
49 | expected = %|
50 | query MyOperation_1($id:ID!) @inContext(lang: "EN") {
51 | storefront(id:$id) { products { _export_id: id } }
52 | }
53 | |
54 |
55 | assert_equal squish_string(expected), source_document
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/test/graphql/stitching/plan_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::Plan" do
6 | def setup
7 | @resolver = GraphQL::Stitching::TypeResolver.new(
8 | location: "products",
9 | type_name: "Storefront",
10 | list: true,
11 | field: "storefronts",
12 | key: GraphQL::Stitching::TypeResolver.parse_key("id"),
13 | arguments: GraphQL::Stitching::TypeResolver.parse_arguments_with_type_defs("ids: $.id", "ids: [ID]"),
14 | )
15 |
16 | @op = GraphQL::Stitching::Plan::Op.new(
17 | step: 2,
18 | after: 1,
19 | location: "products",
20 | operation_type: "query",
21 | path: ["storefronts"],
22 | if_type: "Storefront",
23 | selections: "{ name(lang:$lang) }",
24 | variables: { "lang" => "String!" },
25 | resolver: @resolver.version,
26 | )
27 |
28 | @plan = GraphQL::Stitching::Plan.new(ops: [@op])
29 |
30 | @serialized = {
31 | "ops" => [{
32 | "step" => 2,
33 | "after" => 1,
34 | "location" => "products",
35 | "operation_type" => "query",
36 | "selections" => "{ name(lang:$lang) }",
37 | "variables" => {"lang" => "String!"},
38 | "path" => ["storefronts"],
39 | "if_type" => "Storefront",
40 | "resolver" => @resolver.version,
41 | }],
42 | }
43 | end
44 |
45 | def test_as_json_serializes_a_plan
46 | assert_equal @serialized, JSON.parse(@plan.as_json.to_json)
47 | end
48 |
49 | def test_from_json_deserialized_a_plan
50 | plan = GraphQL::Stitching::Plan.from_json(@serialized)
51 | assert_equal [@op], plan.ops
52 | assert_equal @resolver.version, plan.ops.first.resolver
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/test/graphql/stitching_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching" do
6 | def test_digest_gets_and_sets_hashing_implementation
7 | expected_sha = "f5a163f364ac65dfd8ef60edb3ba39d6c2b44bccc289af3ced96b06e3f25df59"
8 | expected_md5 = "fec9ff7a551c37ef692994407710fa54"
9 |
10 | GraphQL::Stitching.stub_const(:VERSION, "1.5.1") do
11 | fn = GraphQL::Stitching.digest
12 | assert_equal expected_sha, new_type_resolver.version
13 |
14 | GraphQL::Stitching.digest { |str| Digest::MD5.hexdigest(str) }
15 | assert_equal expected_md5, new_type_resolver.version
16 |
17 | GraphQL::Stitching.digest(&fn)
18 | assert_equal expected_sha, new_type_resolver.version
19 | end
20 | end
21 |
22 | def test_gets_and_sets_library_directive_names
23 | stitch_name = GraphQL::Stitching.stitch_directive
24 | visibility_name = GraphQL::Stitching.visibility_directive
25 |
26 | begin
27 | GraphQL::Stitching.stitch_directive = "test"
28 | assert_equal "test", GraphQL::Stitching.stitch_directive
29 |
30 | GraphQL::Stitching.visibility_directive = "test"
31 | assert_equal "test", GraphQL::Stitching.visibility_directive
32 | ensure
33 | GraphQL::Stitching.stitch_directive = stitch_name
34 | GraphQL::Stitching.visibility_directive = visibility_name
35 | end
36 | end
37 |
38 | private
39 |
40 | def new_type_resolver
41 | GraphQL::Stitching::TypeResolver.new(
42 | location: "a",
43 | type_name: "Test",
44 | list: false,
45 | field: "a",
46 | key: GraphQL::Stitching::TypeResolver.parse_key("id"),
47 | arguments: GraphQL::Stitching::TypeResolver.parse_arguments_with_type_defs("id: $.id", "id: ID"),
48 | )
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/test/schemas/merged_child.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module MergedChild
5 | AUTHOR = {
6 | id: "1",
7 | name: "Frank Herbert",
8 | book: {
9 | id: "1",
10 | title: "Dune",
11 | year: 1965,
12 | },
13 | }.freeze
14 |
15 | class ParentSchema < GraphQL::Schema
16 | class Book < GraphQL::Schema::Object
17 | field :id, ID, null: false
18 | field :title, String, null: false
19 | end
20 |
21 | class Author < GraphQL::Schema::Object
22 | field :id, ID, null: false
23 | field :name, String, null: false
24 | field :book, Book, null: true
25 | end
26 |
27 | class Query < GraphQL::Schema::Object
28 | field :author, Author, null: false
29 |
30 | def author
31 | AUTHOR
32 | end
33 |
34 | field :book, Book, null: true do
35 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
36 | argument :id, ID, required: true
37 | end
38 |
39 | def book(id:)
40 | AUTHOR[:book] if AUTHOR[:book][:id] == id
41 | end
42 | end
43 |
44 | query Query
45 | end
46 |
47 | class ChildSchema < GraphQL::Schema
48 | class Book < GraphQL::Schema::Object
49 | field :id, ID, null: false
50 | field :year, Int, null: false
51 | end
52 |
53 | class Query < GraphQL::Schema::Object
54 | field :book, Book, null: true do
55 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
56 | argument :id, ID, required: true
57 | end
58 |
59 | def book(id:)
60 | AUTHOR[:book] if AUTHOR[:book][:id] == id
61 | end
62 | end
63 |
64 | query Query
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_union_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging unions' do
6 |
7 | def test_merges_union_types
8 | a = %{type A { a:Int } union Thing = A type Query { thing:Thing }}
9 | b = %{type B { b:Int } type C { b:Int } union Thing = B | C type Query { thing:Thing }}
10 |
11 | info = compose_definitions({ "a" => a, "b" => b })
12 |
13 | assert_equal ["A", "B", "C"], info.schema.get_type("Thing").possible_types.map(&:graphql_name).sort
14 | end
15 |
16 | def test_merges_union_descriptions
17 | a = %{type A { a:Int } """a""" union Thing = A type Query { thing:Thing }}
18 | b = %{type B { b:Int } """b""" union Thing = B type Query { thing:Thing }}
19 |
20 | info = compose_definitions({ "a" => a, "b" => b }, {
21 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
22 | })
23 |
24 | assert_equal "a/b", info.schema.get_type("Thing").description
25 | end
26 |
27 | def test_merges_union_directives
28 | a = <<~GRAPHQL
29 | directive @fizzbuzz(arg: String!) on UNION
30 | type A { a:Int }
31 | union Thing @fizzbuzz(arg: "a") = A
32 | type Query { thing:Thing }
33 | GRAPHQL
34 |
35 | b = <<~GRAPHQL
36 | directive @fizzbuzz(arg: String!) on UNION
37 | type B { b:Int }
38 | union Thing @fizzbuzz(arg: "b") = B
39 | type Query { thing:Thing }
40 | GRAPHQL
41 |
42 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
43 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
44 | })
45 |
46 | assert_equal "a/b", supergraph.schema.get_type("Thing").directives.first.arguments.keyword_arguments[:arg]
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/puma.rb:
--------------------------------------------------------------------------------
1 | # This configuration file will be evaluated by Puma. The top-level methods that
2 | # are invoked here are part of Puma's configuration DSL. For more information
3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
4 |
5 | # Puma can serve each request in a thread from an internal thread pool.
6 | # The `threads` method setting takes two numbers: a minimum and maximum.
7 | # Any libraries that use thread pools should be configured to match
8 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
9 | # and maximum; this matches the default thread size of Active Record.
10 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
11 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
12 | threads min_threads_count, max_threads_count
13 |
14 | # Specifies that the worker count should equal the number of processors in production.
15 | if ENV["RAILS_ENV"] == "production"
16 | require "concurrent-ruby"
17 | worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
18 | workers worker_count if worker_count > 1
19 | end
20 |
21 | # Specifies the `worker_timeout` threshold that Puma will use to wait before
22 | # terminating a worker in development environments.
23 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
24 |
25 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
26 | port ENV.fetch("PORT") { 3000 }
27 |
28 | # Specifies the `environment` that Puma will run in.
29 | environment ENV.fetch("RAILS_ENV") { "development" }
30 |
31 | # Specifies the `pidfile` that Puma will use.
32 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
33 |
34 | # Allow puma to be restarted by `bin/rails restart` command.
35 | plugin :tmp_restart
36 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/directives.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | module Directives
5 | class Stitch < GraphQL::Schema::Directive
6 | graphql_name "stitch"
7 | locations FIELD_DEFINITION
8 | argument :key, String, required: true
9 | argument :arguments, String, required: false
10 | argument :type_name, String, required: false
11 | repeatable true
12 | end
13 |
14 | class Visibility < GraphQL::Schema::Directive
15 | graphql_name "visibility"
16 | locations(
17 | OBJECT, INTERFACE, UNION, INPUT_OBJECT, ENUM, SCALAR,
18 | FIELD_DEFINITION, ARGUMENT_DEFINITION, INPUT_FIELD_DEFINITION, ENUM_VALUE
19 | )
20 | argument :profiles, [String, null: false], required: true
21 | end
22 |
23 | class SupergraphKey < GraphQL::Schema::Directive
24 | graphql_name "key"
25 | locations OBJECT, INTERFACE, UNION
26 | argument :key, String, required: true
27 | argument :location, String, required: true
28 | repeatable true
29 | end
30 |
31 | class SupergraphResolver < GraphQL::Schema::Directive
32 | graphql_name "resolver"
33 | locations OBJECT, INTERFACE, UNION
34 | argument :location, String, required: true
35 | argument :list, Boolean, required: false
36 | argument :key, String, required: true
37 | argument :field, String, required: true
38 | argument :arguments, String, required: true
39 | argument :argument_types, String, required: true
40 | argument :type_name, String, required: false
41 | repeatable true
42 | end
43 |
44 | class SupergraphSource < GraphQL::Schema::Directive
45 | graphql_name "source"
46 | locations FIELD_DEFINITION
47 | argument :location, String, required: true
48 | repeatable true
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | ## What is Stitching?
2 |
3 | You've probably done this: fetch some objects from an endpoint; then map those into a collection of keys that gets sent to another endpoint for more data; then hook all the results together in your client. This is stitching. GraphQL stitching simply automates this process so that one large _supergraph_ schema can transparently query from many _subgraph_ locations.
4 |
5 | ## Stitching vs. Federation
6 |
7 | Stitching encompasses the raw mechanics of combining GraphQL schemas with cross-referenced objects, and is the underpinning mechanics for major federation frameworks such as [Apollo Federation](https://www.apollographql.com/federation) and [Wundergraph](https://wundergraph.com/). Stitching is generic library behavior that plugs into your server, while federation ecosystems _give you_ a server, a schema deployment pipeline, a control plane, and opinionated management workflows.
8 |
9 | If you're scaling a large distributed architecture with heavy throughput demands, then you'll probably benefit from growing atop a major federation framework. However, if you just have an existing Ruby app and want to hook a few external schemas into its GraphQL API, then incorporating an entire federation framework is probably overkill. Stitching offers good middleground.
10 |
11 | ## The `GraphQL::Stitching` library
12 |
13 | This library contains the component parts for assembling a stitched schema, and rolls the entire workflow up into a `GraphQL::Stitching::Client` class.
14 |
15 | 
16 |
17 | For the most part, this entire library can be driven using just a `Client`. First [compose a supergraph](./composing_a_supergraph.md) with [executables](./executables.md) for each location, configure its [merged types](./merged_types.md), and then [serve the client](./serving_a_supergraph.md) in your app's GraphQL controller.
18 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/merged_child.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/merged_child"
5 |
6 | describe 'GraphQL::Stitching, merged child' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "a" => Schemas::MergedChild::ParentSchema,
10 | "b" => Schemas::MergedChild::ChildSchema,
11 | })
12 |
13 | @expected = {
14 | "author" => {
15 | "name" => "Frank Herbert",
16 | "book" => {
17 | "title" => "Dune",
18 | "year" => 1965,
19 | },
20 | },
21 | }
22 | end
23 |
24 | def test_resolves_fragment_spread_for_parent_of_merged_child
25 | result = plan_and_execute(@supergraph, %|
26 | query {
27 | author { ...AuthorAttrs }
28 | }
29 | fragment AuthorAttrs on Author {
30 | name
31 | book {
32 | title
33 | year
34 | }
35 | }
36 | |)
37 |
38 | assert_equal @expected, result["data"]
39 | end
40 |
41 | def test_resolves_inline_fragment_for_parent_of_merged_child
42 | result = plan_and_execute(@supergraph, %|
43 | query {
44 | author {
45 | ...on Author {
46 | name
47 | book {
48 | title
49 | year
50 | }
51 | }
52 | }
53 | }
54 | |)
55 |
56 | assert_equal @expected, result["data"]
57 | end
58 |
59 | def test_resolves_untyped_fragment_for_parent_of_merged_child
60 | result = plan_and_execute(@supergraph, %|
61 | query {
62 | author {
63 | ... {
64 | name
65 | book {
66 | title
67 | year
68 | }
69 | }
70 | }
71 | }
72 | |)
73 |
74 | assert_equal @expected, result["data"]
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/supergraph/types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Supergraph
5 | module Visibility
6 | def visible?(ctx)
7 | profile = ctx[:visibility_profile]
8 | return true if profile.nil?
9 |
10 | directive = directives.find { _1.graphql_name == GraphQL::Stitching.visibility_directive }
11 | return true if directive.nil?
12 |
13 | profiles = directive.arguments.keyword_arguments[:profiles]
14 | return true if profiles.nil?
15 |
16 | profiles.include?(profile)
17 | end
18 | end
19 |
20 | class ArgumentType < GraphQL::Schema::Argument
21 | include Visibility
22 | end
23 |
24 | class FieldType < GraphQL::Schema::Field
25 | include Visibility
26 | argument_class(ArgumentType)
27 | end
28 |
29 | class InputObjectType < GraphQL::Schema::InputObject
30 | extend Visibility
31 | argument_class(ArgumentType)
32 | end
33 |
34 | module InterfaceType
35 | include GraphQL::Schema::Interface
36 | field_class(FieldType)
37 |
38 | definition_methods do
39 | include Visibility
40 | end
41 | end
42 |
43 | class ObjectType < GraphQL::Schema::Object
44 | extend Visibility
45 | field_class(FieldType)
46 | end
47 |
48 | class EnumValueType < GraphQL::Schema::EnumValue
49 | include Visibility
50 | end
51 |
52 | class EnumType < GraphQL::Schema::Enum
53 | extend Visibility
54 | enum_value_class(EnumValueType)
55 | end
56 |
57 | class ScalarType < GraphQL::Schema::Scalar
58 | extend Visibility
59 | end
60 |
61 | class UnionType < GraphQL::Schema::Union
62 | extend Visibility
63 | end
64 |
65 | BASE_TYPES = {
66 | enum: EnumType,
67 | input_object: InputObjectType,
68 | interface: InterfaceType,
69 | object: ObjectType,
70 | scalar: ScalarType,
71 | union: UnionType,
72 | }.freeze
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/nested_root_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/nested_root"
5 |
6 | describe 'GraphQL::Stitching, nested root scopes' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "a" => Schemas::NestedRoot::Alpha,
10 | "b" => Schemas::NestedRoot::Bravo,
11 | })
12 | end
13 |
14 | def test_nested_root_scopes
15 | source = %|
16 | mutation {
17 | doStuff {
18 | apple
19 | banana
20 | }
21 | }
22 | |
23 |
24 | expected = {
25 | "doStuff" => {
26 | "apple" => "red",
27 | "banana" => "yellow",
28 | }
29 | }
30 |
31 | result = plan_and_execute(@supergraph, source)
32 | assert_equal expected, result["data"]
33 | end
34 |
35 | def test_nested_root_scopes_with_complex_paths
36 | source = %|
37 | mutation {
38 | doThings {
39 | query {
40 | apple
41 | banana
42 | }
43 | }
44 | }
45 | |
46 |
47 | expected = {
48 | "doThings" => [
49 | {
50 | "query" => {
51 | "apple" => "red",
52 | "banana" => "yellow",
53 | }
54 | },
55 | {
56 | "query" => {
57 | "apple" => "red",
58 | "banana" => "yellow",
59 | }
60 | },
61 | ]
62 | }
63 |
64 | result = plan_and_execute(@supergraph, source)
65 | assert_equal expected, result["data"]
66 | end
67 |
68 | def test_nested_root_scopes_repath_errors
69 | source = %|
70 | mutation {
71 | doThing {
72 | query {
73 | errorA
74 | errorB
75 | }
76 | }
77 | }
78 | |
79 |
80 | expected = [
81 | { "message" => "a", "path" => ["doThing", "query", "errorA"] },
82 | { "message" => "b", "path" => ["doThing", "query", "errorB"] },
83 | ]
84 |
85 | result = plan_and_execute(@supergraph, source)
86 | assert_equal expected, result["errors"]
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/test/schemas/shareables.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module Shareables
5 | # ShareableA
6 |
7 | class ShareableA < GraphQL::Schema
8 | class Gizmo < GraphQL::Schema::Object
9 | field :a, String, null: false
10 | field :b, String, null: false
11 | field :c, String, null: false
12 | end
13 |
14 | class Gadget < GraphQL::Schema::Object
15 | field :id, ID, null: false
16 | field :name, String, null: false
17 | field :gizmo, Gizmo, null: false
18 | field :unique_to_a, String, null: false
19 |
20 | def gizmo
21 | { a: "apple", b: "banana", c: "coconut" }
22 | end
23 | end
24 |
25 | class Query < GraphQL::Schema::Object
26 | field :gadget_a, Gadget, null: false do
27 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
28 | argument :id, ID, required: true
29 | end
30 |
31 | def gadget_a(id:)
32 | { id: id, name: "A#{id}", unique_to_a: "AA" }
33 | end
34 | end
35 |
36 | query Query
37 | end
38 |
39 | # ShareableB
40 |
41 | class ShareableB < GraphQL::Schema
42 | class Gizmo < GraphQL::Schema::Object
43 | field :a, String, null: false
44 | field :b, String, null: false
45 | field :c, String, null: false
46 | end
47 |
48 | class Gadget < GraphQL::Schema::Object
49 | field :id, ID, null: false
50 | field :name, String, null: false
51 | field :gizmo, Gizmo, null: false
52 | field :unique_to_b, String, null: false
53 |
54 | def gizmo
55 | { a: "aardvark", b: "bat", c: "cat" }
56 | end
57 | end
58 |
59 | class Query < GraphQL::Schema::Object
60 | field :gadget_b, Gadget, null: false do
61 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
62 | argument :id, ID, required: true
63 | end
64 |
65 | def gadget_b(id:)
66 | { id: id, name: "B#{id}", unique_to_b: "BB" }
67 | end
68 | end
69 |
70 | query Query
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/test/schemas/visibility.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module Visibility
5 | RECORDS = [
6 | { id: "1", price: 20.99, msrp: 10.99, quantity_available: 23, quantity_in_stock: 35 },
7 | { id: "2", price: 99.99, msrp: 69.99, quantity_available: 77, quantity_in_stock: 100 },
8 | ].freeze
9 |
10 | class PriceSchema < GraphQL::Schema
11 | class Sprocket < GraphQL::Schema::Object
12 | field :id, ID, null: false do |f|
13 | f.directive(GraphQL::Stitching::Directives::Visibility, profiles: [])
14 | end
15 |
16 | field :price, Float, null: false
17 |
18 | field :msrp, Float, null: false do |f|
19 | f.directive(GraphQL::Stitching::Directives::Visibility, profiles: ["private"])
20 | end
21 | end
22 |
23 | class Query < GraphQL::Schema::Object
24 | field :sprocket, Sprocket, null: true do |f|
25 | f.directive(GraphQL::Stitching::Directives::Stitch, key: "id")
26 | f.argument(:id, ID, required: true)
27 | end
28 |
29 | def sprocket(id:)
30 | RECORDS.find { _1[:id] == id }
31 | end
32 | end
33 |
34 | query Query
35 | end
36 |
37 | class InventorySchema < GraphQL::Schema
38 | class Sprocket < GraphQL::Schema::Object
39 | field :id, ID, null: false
40 |
41 | field :quantity_available, Integer, null: false
42 |
43 | field :quantity_in_stock, Integer, null: false do |f|
44 | f.directive(GraphQL::Stitching::Directives::Visibility, profiles: ["private"])
45 | end
46 | end
47 |
48 | class Query < GraphQL::Schema::Object
49 | field :sprockets, [Sprocket], null: false do |f|
50 | f.directive(GraphQL::Stitching::Directives::Stitch, key: "id")
51 | f.directive(GraphQL::Stitching::Directives::Visibility, profiles: ["private"])
52 | f.argument(:ids, [ID, null: false], required: true)
53 | end
54 |
55 | def sprockets(ids:)
56 | ids.map { |id| RECORDS.find { _1[:id] == id } }
57 | end
58 | end
59 |
60 | query Query
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/graphql/stitching/federation_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../schemas/federation"
5 |
6 | describe "GraphQL::Stitching::Federation" do
7 |
8 | def test_federation_to_stitching
9 | @supergraph = compose_definitions({
10 | "federation" => Schemas::Federation::Federation1,
11 | "stitching" => Schemas::Federation::Stitching,
12 | })
13 |
14 | query = %|
15 | query {
16 | widgets(upcs: ["1"]) {
17 | megahertz
18 | model
19 | sprockets {
20 | cogs
21 | diameter
22 | }
23 | }
24 | }
25 | |
26 |
27 | expected = {
28 | "data" => {
29 | "widgets" => [{
30 | "megahertz" => 3,
31 | "model" => "Basic",
32 | "sprockets" => [
33 | { "cogs" => 23, "diameter" => 77 },
34 | { "cogs" => 14, "diameter" => 20 },
35 | ],
36 | }],
37 | },
38 | }
39 |
40 | result = plan_and_execute(@supergraph, query) do |plan|
41 | assert_equal ["stitching", "federation", "stitching"], plan.ops.map(&:location)
42 | end
43 |
44 | assert_equal expected, result
45 | end
46 |
47 | def test_federation_to_federation
48 | @supergraph = compose_definitions({
49 | "federation1" => Schemas::Federation::Federation1,
50 | "federation2" => Schemas::Federation::Federation2,
51 | })
52 |
53 | query = %|
54 | query {
55 | widget {
56 | megahertz
57 | model
58 | sprockets {
59 | cogs
60 | diameter
61 | }
62 | }
63 | }
64 | |
65 |
66 | expected = {
67 | "data" => {
68 | "widget" => {
69 | "megahertz" => 3,
70 | "model" => "Basic",
71 | "sprockets" => [
72 | { "cogs" => 23, "diameter" => 77 },
73 | { "cogs" => 14, "diameter" => 20 },
74 | ],
75 | },
76 | },
77 | }
78 |
79 | result = plan_and_execute(@supergraph, query) do |plan|
80 | assert_equal ["federation2", "federation1", "federation2"], plan.ops.map(&:location)
81 | end
82 |
83 | assert_equal expected, result
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/examples/subscriptions/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | ruby "3.1.1"
4 |
5 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
6 | gem "rails", "~> 7.1.3", ">= 7.1.3.4"
7 |
8 | # Use sqlite3 as the database for Active Record
9 | gem "sqlite3", "~> 1.4"
10 |
11 | # Use the Puma web server [https://github.com/puma/puma]
12 | gem "puma", ">= 5.0"
13 |
14 | # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
15 | gem "importmap-rails"
16 |
17 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
18 | gem "turbo-rails"
19 |
20 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
21 | gem "stimulus-rails"
22 |
23 | # Build JSON APIs with ease [https://github.com/rails/jbuilder]
24 | gem "jbuilder"
25 |
26 | # Use Redis adapter to run Action Cable in production
27 | gem "redis", ">= 4.0.1"
28 |
29 | # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
30 | # gem "kredis"
31 |
32 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
33 | # gem "bcrypt", "~> 3.1.7"
34 |
35 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
36 | gem "tzinfo-data", platforms: %i[ mswin mswin64 mingw x64_mingw jruby ]
37 |
38 | # Reduces boot times through caching; required in config/boot.rb
39 | gem "bootsnap", require: false
40 |
41 | gem "graphql", "~> 2.3.0"
42 |
43 | group :development, :test do
44 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
45 | gem "debug", platforms: %i[ mri mswin mswin64 mingw x64_mingw ]
46 | end
47 |
48 | group :development do
49 | # Use console on exceptions pages [https://github.com/rails/web-console]
50 | gem "web-console"
51 |
52 | # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
53 | # gem "rack-mini-profiler"
54 |
55 | # Speed up commands on slow machines / big apps [https://github.com/rails/spring]
56 | # gem "spring"
57 |
58 | gem "error_highlight", ">= 0.4.0", platforms: [:ruby]
59 | end
60 |
61 | group :test do
62 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
63 | gem "capybara"
64 | gem "selenium-webdriver"
65 | end
66 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/subscriptions_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/example"
5 | require_relative "../../../schemas/subscriptions"
6 |
7 | describe 'GraphQL::Stitching, subscriptions' do
8 | def setup
9 | @supergraph = compose_definitions({
10 | "a" => Schemas::Example::Products,
11 | "b" => Schemas::Example::Manufacturers,
12 | "c" => {
13 | schema: Schemas::Subscriptions::SubscriptionSchema,
14 | executable: -> (_req, _src, _vars) {
15 | {
16 | "data" => {
17 | "updateToProduct" => {
18 | "product" => { "_export_upc" => "1", "_export___typename" => "Product" },
19 | "manufacturer" => nil,
20 | },
21 | },
22 | }
23 | },
24 | },
25 | })
26 |
27 | @query = %|
28 | subscription {
29 | updateToProduct(upc: "1") {
30 | product { name }
31 | manufacturer { name }
32 | }
33 | }
34 | |
35 |
36 | @client = GraphQL::Stitching::Client.new(supergraph: @supergraph)
37 | end
38 |
39 | def test_subscription_stitches_subscribe_request
40 | result = @client.execute(@query)
41 | expected = {
42 | "data" => {
43 | "updateToProduct" => {
44 | "product" => { "name" => "iPhone" },
45 | "manufacturer" => nil,
46 | },
47 | },
48 | }
49 |
50 | assert_equal expected, result.to_h
51 | end
52 |
53 | def test_subscription_provides_update_handler
54 | result = @client.execute(@query)
55 | result.to_h.merge!({
56 | "data" => {
57 | "updateToProduct" => {
58 | "product" => { "_export_upc" => "1", "_export___typename" => "Product" },
59 | "manufacturer" => { "_export_id" => "1", "_export___typename" => "Manufacturer" },
60 | },
61 | },
62 | })
63 |
64 | expected = {
65 | "data" => {
66 | "updateToProduct" => {
67 | "product" => { "name" => "iPhone" },
68 | "manufacturer" => { "name" => "Apple" },
69 | },
70 | },
71 | }
72 |
73 | assert_equal expected, result.context[:stitch_subscription_update].call(result).to_h
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/test/schemas/mutations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module Mutations
5 | RECORDS = []
6 |
7 | class << self
8 | def reset
9 | while RECORDS.length > 0
10 | RECORDS.pop
11 | end
12 | end
13 |
14 | def creation_order
15 | RECORDS.map { _1[:id] }
16 | end
17 | end
18 |
19 | # Mutations A
20 |
21 | class MutationsA < GraphQL::Schema
22 | class Record < GraphQL::Schema::Object
23 | field :id, ID, null: false
24 | field :a, String, null: false
25 | field :via, String, null: false
26 | end
27 |
28 | class Query < GraphQL::Schema::Object
29 | field :recordA, Record, null: true do
30 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
31 | argument :id, ID, required: true
32 | end
33 |
34 | def recordA(id:)
35 | RECORDS.find { _1[:id] == id }
36 | end
37 | end
38 |
39 | class Mutation < GraphQL::Schema::Object
40 | field :addViaA, Record, null: false
41 |
42 | def addViaA
43 | id = RECORDS.length + 1
44 | RECORDS << { id: id.to_s, via: "A", a: "A#{id}", b: "B#{id}" }
45 | RECORDS.last
46 | end
47 | end
48 |
49 | query Query
50 | mutation Mutation
51 | end
52 |
53 | # Mutations B
54 |
55 | class MutationsB < GraphQL::Schema
56 | class Record < GraphQL::Schema::Object
57 | field :id, ID, null: false
58 | field :b, String, null: false
59 | field :via, String, null: false
60 | end
61 |
62 | class Query < GraphQL::Schema::Object
63 | field :recordB, Record, null: true do
64 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
65 | argument :id, ID, required: true
66 | end
67 |
68 | def recordB(id:)
69 | RECORDS.find { _1[:id] == id }
70 | end
71 | end
72 |
73 | class Mutation < GraphQL::Schema::Object
74 | field :addViaB, Record, null: false
75 |
76 | def addViaB
77 | id = RECORDS.length + 1
78 | RECORDS << { id: id.to_s, via: "B", a: "A#{id}", b: "B#{id}" }
79 | RECORDS.last
80 | end
81 | end
82 |
83 | query Query
84 | mutation Mutation
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/test/graphql/stitching/planner/plan_introspection_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/example"
5 |
6 | describe "GraphQL::Stitching::Planner, introspection" do
7 | def setup
8 | a = "type Apple { name: String } type Query { a:Apple }"
9 | b = "type Banana { name: String } type Query { b:Banana }"
10 | @supergraph = compose_definitions({ "a" => a, "b" => b })
11 | end
12 |
13 | def test_plans_full_introspection_query
14 | plan = GraphQL::Stitching::Request.new(
15 | @supergraph,
16 | GraphQL::Introspection::INTROSPECTION_QUERY,
17 | operation_name: "IntrospectionQuery",
18 | ).plan
19 |
20 | assert_equal 1, plan.ops.length
21 | assert_equal "__super", plan.ops.first.location
22 | end
23 |
24 | def test_stitches_introspection_with_other_locations
25 | plan = GraphQL::Stitching::Request.new(
26 | @supergraph,
27 | %|{ __schema { queryType { name } } a { name } }|,
28 | ).plan
29 |
30 | assert_equal 2, plan.ops.length
31 |
32 | assert_keys plan.ops[0].as_json, {
33 | location: "__super",
34 | selections: %|{ __schema { queryType { name } } }|,
35 | }
36 |
37 | assert_keys plan.ops[1].as_json, {
38 | location: "a",
39 | selections: %|{ a { name } }|,
40 | }
41 | end
42 |
43 | def test_passes_through_typename_selections
44 | plan = GraphQL::Stitching::Request.new(
45 | @supergraph,
46 | %|{ a { name __typename } }|,
47 | ).plan
48 |
49 | assert_equal 1, plan.ops.length
50 |
51 | assert_keys plan.ops.first.as_json, {
52 | location: "a",
53 | selections: %|{ a { name __typename } }|,
54 | }
55 | end
56 |
57 | def test_errors_for_reserved_selection_alias
58 | assert_error %|Alias "_export_name" is not allowed because "_export_" is a reserved prefix| do
59 | GraphQL::Stitching::Request.new(
60 | @supergraph,
61 | %|{ a { _export_name: name } }|,
62 | ).plan
63 | end
64 | end
65 |
66 | def test_errors_for_reserved_typehint_alias
67 | assert_error %|Alias "_export___typename" is not allowed because "_export_" is a reserved prefix| do
68 | GraphQL::Stitching::Request.new(
69 | @supergraph,
70 | %|{ a { _export___typename: __typename } }|,
71 | ).plan
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/type_resolver.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "type_resolver/arguments"
4 | require_relative "type_resolver/keys"
5 |
6 | module GraphQL
7 | module Stitching
8 | # Defines a type resolver query that provides direct access to an entity type.
9 | class TypeResolver
10 | extend ArgumentsParser
11 | extend KeysParser
12 |
13 | class << self
14 | # only intended for testing...
15 | def use_static_version?
16 | @use_static_version ||= false
17 | end
18 | end
19 |
20 | # location name providing the resolver query.
21 | attr_reader :location
22 |
23 | # name of merged type fulfilled through this resolver.
24 | attr_reader :type_name
25 |
26 | # name of the root field to query.
27 | attr_reader :field
28 |
29 | # a key field to select from prior locations, sent as resolver argument.
30 | attr_reader :key
31 |
32 | # parsed resolver Argument structures.
33 | attr_reader :arguments
34 |
35 | def initialize(
36 | location:,
37 | type_name: nil,
38 | list: false,
39 | field: nil,
40 | key: nil,
41 | arguments: nil
42 | )
43 | @location = location
44 | @type_name = type_name
45 | @list = list
46 | @field = field
47 | @key = key
48 | @arguments = arguments
49 | end
50 |
51 | # specifies when the resolver is a list query.
52 | def list?
53 | @list
54 | end
55 |
56 | def version
57 | @version ||= if self.class.use_static_version?
58 | [location, field, key.to_definition, type_name].join(".")
59 | else
60 | Stitching.digest.call("#{Stitching::VERSION}/#{as_json.to_json}")
61 | end
62 | end
63 |
64 | def ==(other)
65 | self.class == other.class && self.as_json == other.as_json
66 | end
67 |
68 | def as_json
69 | {
70 | location: location,
71 | type_name: type_name,
72 | list: list?,
73 | field: field,
74 | key: key.to_definition,
75 | arguments: arguments.map(&:to_definition).join(", "),
76 | argument_types: arguments.map(&:to_type_definition).join(", "),
77 | }.tap(&:compact!)
78 | end
79 |
80 | def inspect
81 | as_json.to_json
82 | end
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/arguments_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/arguments"
5 |
6 | describe 'GraphQL::Stitching, arguments' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "args1" => Schemas::Arguments::Arguments1,
10 | "args2" => Schemas::Arguments::Arguments2,
11 | })
12 | end
13 |
14 | def test_stitches_with_enum_argument
15 | query = %|{ allMovies { id status } }|
16 | result = plan_and_execute(@supergraph, query)
17 | expected = {
18 | "allMovies" => [
19 | { "id" => "1", "status" => "STREAMING" },
20 | { "id" => "2", "status" => nil },
21 | { "id" => "3", "status" => "STREAMING" },
22 | ],
23 | }
24 |
25 | assert_equal expected, result["data"]
26 | end
27 |
28 | def test_stitches_with_input_object_key
29 | query = %|{ allMovies { id director { name } } }|
30 | result = plan_and_execute(@supergraph, query)
31 | expected = {
32 | "allMovies" => [
33 | { "id" => "1", "director" => { "name" => "Steven Spielberg" } },
34 | { "id" => "2", "director" => { "name" => "Steven Spielberg" } },
35 | { "id" => "3", "director" => { "name" => "Christopher Nolan" } },
36 | ],
37 | }
38 |
39 | assert_equal expected, result["data"]
40 | end
41 |
42 | def test_stitches_with_scalar_key
43 | query = %|{ allMovies { id studio { name } } }|
44 | result = plan_and_execute(@supergraph, query)
45 | expected = {
46 | "allMovies" => [
47 | { "id" => "1", "studio" => { "name" => "Universal" } },
48 | { "id" => "2", "studio" => { "name" => "Lucasfilm" } },
49 | { "id" => "3", "studio" => { "name" => "Syncopy" } },
50 | ],
51 | }
52 |
53 | assert_equal expected, result["data"]
54 | end
55 |
56 | def test_stitches_with_literal_arguments
57 | query = %|{ allMovies { id genres { name } } }|
58 | result = plan_and_execute(@supergraph, query)
59 | expected = {
60 | "allMovies" => [
61 | { "id" => "1", "genres" => [{ "name" => "action/adventure" }, { "name" => "action/sci-fi" }] },
62 | { "id" => "2", "genres" => [{ "name" => "action" }, { "name" => "action/adventure" }] },
63 | { "id" => "3", "genres" => [{ "name" => "action" }, { "name" => "action/thriller" }] },
64 | ],
65 | }
66 |
67 | assert_equal expected, result["data"]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/planner/step.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Planner
5 | # A planned step in the sequence of stitching entrypoints together.
6 | # This is a mutable object that may change throughout the planning process.
7 | # It ultimately builds an immutable Plan::Op at the end of planning.
8 | class Step
9 | GRAPHQL_PRINTER = GraphQL::Language::Printer.new
10 |
11 | attr_reader :index, :location, :parent_type, :operation_type, :path
12 | attr_accessor :after, :selections, :variables, :resolver
13 |
14 | def initialize(
15 | location:,
16 | parent_type:,
17 | index:,
18 | after: nil,
19 | operation_type: QUERY_OP,
20 | selections: [],
21 | variables: {},
22 | path: [],
23 | resolver: nil
24 | )
25 | @location = location
26 | @parent_type = parent_type
27 | @index = index
28 | @after = after
29 | @operation_type = operation_type
30 | @selections = selections
31 | @variables = variables
32 | @path = path
33 | @resolver = resolver
34 | end
35 |
36 | def to_plan_op
37 | GraphQL::Stitching::Plan::Op.new(
38 | step: @index,
39 | after: @after,
40 | location: @location,
41 | operation_type: @operation_type,
42 | selections: rendered_selections,
43 | variables: rendered_variables,
44 | path: @path,
45 | if_type: type_condition,
46 | resolver: @resolver&.version,
47 | )
48 | end
49 |
50 | private
51 |
52 | # Concrete types going to a resolver report themselves as a type condition.
53 | # This is used by the executor to evalute which planned fragment selections
54 | # actually apply to the resolved object types.
55 | def type_condition
56 | @parent_type.graphql_name if @resolver && !parent_type.kind.abstract?
57 | end
58 |
59 | def rendered_selections
60 | op = GraphQL::Language::Nodes::OperationDefinition.new(operation_type: "", selections: @selections)
61 | GRAPHQL_PRINTER.print(op).gsub!(/\s+/, " ").strip!
62 | end
63 |
64 | def rendered_variables
65 | @variables.each_with_object({}) do |(variable_name, value_type), memo|
66 | memo[variable_name] = GRAPHQL_PRINTER.print(value_type)
67 | end
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/introspection_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/example"
5 |
6 | describe 'GraphQL::Stitching, introspection' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "products" => Schemas::Example::Products,
10 | "storefronts" => Schemas::Example::Storefronts,
11 | "manufacturers" => Schemas::Example::Manufacturers,
12 | })
13 | end
14 |
15 | def test_performs_full_introspection
16 | result = plan_and_execute(@supergraph, GraphQL::Introspection::INTROSPECTION_QUERY)
17 |
18 | introspection_types = result.dig("data", "__schema", "types").map { _1["name"] }
19 | expected_types = ["Manufacturer", "Product", "Query", "Storefront"]
20 | expected_types += ["Boolean", "Float", "ID", "Int", "String"]
21 | expected_types += @supergraph.memoized_introspection_types.keys
22 | assert_equal expected_types.sort, introspection_types.sort
23 | end
24 |
25 | def test_performs_schema_introspection_with_other_stitching
26 | result = plan_and_execute(@supergraph, %|
27 | {
28 | __schema {
29 | queryType { name }
30 | }
31 | product(upc: "1") {
32 | name
33 | manufacturer { name }
34 | }
35 | }
36 | |)
37 |
38 | expected = {
39 | "data" => {
40 | "__schema" => {
41 | "queryType" => {
42 | "name" => "Query",
43 | },
44 | },
45 | "product" => {
46 | "name" => "iPhone",
47 | "manufacturer" => {
48 | "name" => "Apple",
49 | },
50 | },
51 | },
52 | }
53 |
54 | assert_equal expected, result.to_h
55 | end
56 |
57 | def test_performs_type_introspection_with_other_stitching
58 | result = plan_and_execute(@supergraph, %|
59 | {
60 | __type(name: "Product") {
61 | name
62 | kind
63 | }
64 | product(upc: "1") {
65 | name
66 | manufacturer { name }
67 | }
68 | }
69 | |)
70 |
71 | expected = {
72 | "data" => {
73 | "__type" => {
74 | "name" => "Product",
75 | "kind" => "OBJECT",
76 | },
77 | "product" => {
78 | "name" => "iPhone",
79 | "manufacturer" => {
80 | "name" => "Apple",
81 | },
82 | },
83 | },
84 | }
85 |
86 | assert_equal expected, result.to_h
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/test/schemas/multiple_keys.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module MultipleKeys
5 | PRODUCTS = [
6 | { id: '1', upc: 'xyz', name: 'iPhone', location: 'Toronto', edition: 'Spring' },
7 | ].freeze
8 |
9 | # Storefronts
10 |
11 | class Storefronts < GraphQL::Schema
12 | class Product < GraphQL::Schema::Object
13 | field :id, ID, null: false
14 | field :location, String, null: false
15 | end
16 |
17 | class Query < GraphQL::Schema::Object
18 | field :storefronts_product_by_id, Product, null: false do
19 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
20 | argument :id, ID, required: true
21 | end
22 |
23 | def storefronts_product_by_id(id:)
24 | PRODUCTS.find { _1[:id] == id }
25 | end
26 | end
27 |
28 | query Query
29 | end
30 |
31 | # Products
32 |
33 | class Products < GraphQL::Schema
34 | class Product < GraphQL::Schema::Object
35 | field :id, ID, null: false
36 | field :upc, ID, null: false
37 | field :name, String, null: false
38 | end
39 |
40 | class Query < GraphQL::Schema::Object
41 | field :products_product_by_id, Product, null: false do
42 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
43 | argument :id, ID, required: true
44 | end
45 |
46 | def products_product_by_id(id:)
47 | PRODUCTS.find { _1[:id] == id }
48 | end
49 |
50 | field :products_product_by_upc, Product, null: false do
51 | directive GraphQL::Stitching::Directives::Stitch, key: "upc"
52 | argument :upc, ID, required: true
53 | end
54 |
55 | def products_product_by_upc(upc:)
56 | PRODUCTS.find { _1[:upc] == upc }
57 | end
58 | end
59 |
60 | query Query
61 | end
62 |
63 | # Catelogs
64 |
65 | class Catelogs < GraphQL::Schema
66 | class Product < GraphQL::Schema::Object
67 | field :upc, ID, null: false
68 | field :edition, String, null: false
69 | end
70 |
71 | class Query < GraphQL::Schema::Object
72 | field :catalogs_product_by_upc, Product, null: false do
73 | directive GraphQL::Stitching::Directives::Stitch, key: "upc"
74 | argument :upc, ID, required: true
75 | end
76 |
77 | def catalogs_product_by_upc(upc:)
78 | PRODUCTS.find { _1[:upc] == upc }
79 | end
80 | end
81 |
82 | query Query
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/skip_include_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/example"
5 |
6 | describe 'GraphQL::Stitching, skip/include' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "products" => Schemas::Example::Products,
10 | "storefronts" => Schemas::Example::Storefronts,
11 | "manufacturers" => Schemas::Example::Manufacturers,
12 | })
13 | end
14 |
15 | def test_skips_partial_object_fields
16 | query = %|
17 | query($id: ID!) {
18 | storefront(id: $id) {
19 | products {
20 | upc
21 | manufacturer @skip(if: true) {
22 | name
23 | }
24 | }
25 | }
26 | }
27 | |
28 |
29 | result = plan_and_execute(@supergraph, query, { "id" => "1" })
30 | expected = {
31 | "storefront" => {
32 | "products" => [
33 | { "upc" => "1" },
34 | { "upc" => "2" },
35 | ],
36 | },
37 | }
38 |
39 | assert_equal expected, result.dig("data")
40 | end
41 |
42 | def test_skips_all_object_fields
43 | query = %|
44 | query($id: ID!) {
45 | storefront(id: $id) {
46 | products {
47 | manufacturer @skip(if: true) {
48 | name
49 | }
50 | }
51 | }
52 | }
53 | |
54 |
55 | result = plan_and_execute(@supergraph, query, { "id" => "1" })
56 | expected = {
57 | "storefront" => {
58 | "products" => [
59 | {},
60 | {},
61 | ],
62 | },
63 | }
64 |
65 | assert_equal expected, result.dig("data")
66 | end
67 |
68 | def test_skips_partial_root_fields
69 | query = %|{
70 | product(upc: "1") {
71 | upc
72 | }
73 | storefront(id: "1") @skip(if: true) {
74 | id
75 | }
76 | }|
77 |
78 | result = plan_and_execute(@supergraph, query)
79 | expected = {
80 | "product" => { "upc" => "1" }
81 | }
82 |
83 | assert_equal expected, result.dig("data")
84 | end
85 |
86 | def test_skips_all_root_fields
87 | query = %|
88 | query($id: ID!) {
89 | storefront(id: $id) @skip(if: true) {
90 | id
91 | }
92 | }
93 | |
94 |
95 | result = plan_and_execute(@supergraph, query, { "id" => "1" })
96 | expected = {}
97 |
98 | assert_equal expected, result.dig("data")
99 | end
100 | end
101 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/validate_interfaces_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, validate interfaces' do
6 | def test_errors_for_unmatched_types_in_inherited_interfaces
7 | a = %|
8 | interface Widget { id: ID! value: String! }
9 | type Gizmo implements Widget { id: ID! value: String! }
10 | type Query { a: Gizmo }
11 | |
12 | b = %|
13 | interface Widget { id: ID! }
14 | type Gadget implements Widget { id: ID! value: Int! }
15 | type Query { b: Gadget }
16 | |
17 |
18 | assert_error('Incompatible named types between field Gadget.value of type Int! and interface Widget.value of type String!', ValidationError) do
19 | compose_definitions({ "a" => a, "b" => b })
20 | end
21 | end
22 |
23 | def test_errors_for_unmatched_list_in_inherited_interfaces
24 | a = %|
25 | interface Widget { id: ID! value: [String]! }
26 | type Gizmo implements Widget { id: ID! value: [String]! }
27 | type Query { a: Gizmo }
28 | |
29 | b = %|
30 | interface Widget { id: ID! }
31 | type Gadget implements Widget { id: ID! value: String! }
32 | type Query { b: Gadget }
33 | |
34 |
35 | assert_error('Incompatible list structures between field Gadget.value of type String! and interface Widget.value of type [String]!', ValidationError) do
36 | compose_definitions({ "a" => a, "b" => b })
37 | end
38 | end
39 |
40 | def test_errors_for_unmatched_nullability_in_inherited_interfaces
41 | a = %|
42 | interface Widget { id: ID! value: String! }
43 | type Gizmo implements Widget { id: ID! value: String! }
44 | type Query { a: Gizmo }
45 | |
46 | b = %|
47 | interface Widget { id: ID! }
48 | type Gadget implements Widget { id: ID! value: String }
49 | type Query { b: Gadget }
50 | |
51 |
52 | assert_error('Incompatible nullability between field Gadget.value of type String and interface Widget.value of type String!', ValidationError) do
53 | compose_definitions({ "a" => a, "b" => b })
54 | end
55 | end
56 |
57 | def test_errors_for_missing_fields_in_inherited_interfaces
58 | a = %|
59 | interface Widget { id: ID! value: String! }
60 | type Gizmo implements Widget { id: ID! value: String! }
61 | type Query { a: Gizmo }
62 | |
63 | b = %|
64 | interface Widget { id: ID! }
65 | type Gadget implements Widget { id: ID! }
66 | type Query { b: Gadget }
67 | |
68 |
69 | assert_error('Type Gadget does not implement a `value` field in any location, which is required by interface Widget', ValidationError) do
70 | compose_definitions({ "a" => a, "b" => b })
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/test/test_helper_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "Test Helpers" do
6 | def test_squish_string
7 | string = %|
8 | {
9 | apple {
10 | ...on Apple {
11 | id
12 | color
13 | }
14 | }
15 | }
16 | |
17 | assert_equal "{ apple { ...on Apple { id color } } }", squish_string(string)
18 | end
19 |
20 | def test_sorted_selection_matcher_formats_selections
21 | original = %|{
22 | banana
23 | ...on Coconut {
24 | banana
25 | apple
26 | }
27 | coconut
28 | ... CoconutAttrs
29 | ...on Apple {
30 | coconut
31 | apple
32 | banana
33 | }
34 | apple
35 | ... BananaAttrs
36 | }|
37 |
38 | expected = %|query {
39 | apple
40 | banana
41 | coconut
42 | ... on Apple {
43 | apple
44 | banana
45 | coconut
46 | }
47 | ... on Coconut {
48 | apple
49 | banana
50 | }
51 | ...BananaAttrs
52 | ...CoconutAttrs
53 | }|
54 |
55 | matcher = SortedSelectionMatcher.new(original)
56 | assert_equal squish_string(expected), squish_string(matcher.source)
57 | assert matcher.match?(expected)
58 | end
59 |
60 | def test_sorted_selection_matcher_matches_fields
61 | matcher = SortedSelectionMatcher.new(%|{ banana apple }|)
62 | assert matcher.match?(%|{ apple banana }|)
63 | refute matcher.match?(%|{ apple coconut }|)
64 | refute matcher.match?(%|{ apple }|)
65 | end
66 |
67 | def test_sorted_selection_matcher_matches_inline_fragments
68 | matcher = SortedSelectionMatcher.new(%|{ ... on B { id } ...on A { id } }|)
69 | assert matcher.match?(%|{ ...on A { id } ...on B { id } }|)
70 | refute matcher.match?(%|{ ...on A { id } ...on B { key } }|)
71 | refute matcher.match?(%|{ ...on B { id } ...on C { id } }|)
72 | end
73 |
74 | def test_sorted_selection_matcher_matches_fragment_spreads
75 | matcher = SortedSelectionMatcher.new(%|{ ...B ...A }|)
76 | assert matcher.match?(%|{ ... A ... B }|)
77 | refute matcher.match?(%|{ ...A ...B ...C }|)
78 | refute matcher.match?(%|{ ...A ... C }|)
79 | end
80 |
81 | def test_use_static_version_is_false_by_default
82 | assert_equal false, GraphQL::Stitching::TypeResolver.use_static_version?
83 | end
84 |
85 | def test_use_static_version_is_true_in_helper_block
86 | begin
87 | with_static_resolver_version do
88 | assert_equal true, GraphQL::Stitching::TypeResolver.use_static_version?
89 | raise "block interrupt"
90 | end
91 | rescue
92 | assert_equal false, GraphQL::Stitching::TypeResolver.use_static_version?
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/lib/graphql/stitching.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "graphql"
4 |
5 | module GraphQL
6 | module Stitching
7 | # scope name of query operations.
8 | QUERY_OP = "query"
9 |
10 | # scope name of mutation operations.
11 | MUTATION_OP = "mutation"
12 |
13 | # scope name of subscription operations.
14 | SUBSCRIPTION_OP = "subscription"
15 |
16 | # introspection typename field.
17 | TYPENAME = "__typename"
18 |
19 | # @api private
20 | EMPTY_OBJECT = {}.freeze
21 |
22 | # @api private
23 | EMPTY_ARRAY = [].freeze
24 |
25 | class StitchingError < StandardError; end
26 | class CompositionError < StitchingError; end
27 | class ValidationError < CompositionError; end
28 | class DocumentError < StandardError
29 | def initialize(element)
30 | super("Invalid #{element} encountered in document")
31 | end
32 | end
33 |
34 | class << self
35 | # Proc used to compute digests; uses SHA2 by default.
36 | # @returns [Proc] proc used to compute digests.
37 | def digest(&block)
38 | if block_given?
39 | @digest = block
40 | else
41 | @digest ||= ->(str) { Digest::SHA2.hexdigest(str) }
42 | end
43 | end
44 |
45 | # Name of the directive used to mark type resolvers.
46 | # @returns [String] name of the type resolver directive.
47 | def stitch_directive
48 | @stitch_directive ||= "stitch"
49 | end
50 |
51 | attr_writer :stitch_directive
52 |
53 | # Name of the directive used to denote member visibilities.
54 | # @returns [String] name of the visibility directive.
55 | def visibility_directive
56 | @visibility_directive ||= "visibility"
57 | end
58 |
59 | attr_writer :visibility_directive
60 |
61 | MIN_VISIBILITY_VERSION = "2.5.3"
62 |
63 | # @returns Boolean true if GraphQL::Schema::Visibility is fully supported
64 | def supports_visibility?
65 | return @supports_visibility if defined?(@supports_visibility)
66 |
67 | # Requires `Visibility` (v2.4) with nil profile support (v2.5.3)
68 | @supports_visibility = Gem::Version.new(GraphQL::VERSION) >= Gem::Version.new(MIN_VISIBILITY_VERSION)
69 | end
70 | end
71 | end
72 | end
73 |
74 | require_relative "stitching/directives"
75 | require_relative "stitching/supergraph"
76 | require_relative "stitching/client"
77 | require_relative "stitching/composer"
78 | require_relative "stitching/executor"
79 | require_relative "stitching/http_executable"
80 | require_relative "stitching/plan"
81 | require_relative "stitching/planner"
82 | require_relative "stitching/request"
83 | require_relative "stitching/type_resolver"
84 | require_relative "stitching/util"
85 | require_relative "stitching/version"
86 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/plan.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL
4 | module Stitching
5 | # Immutable structures representing a query plan.
6 | # May serialize to/from JSON.
7 | class Plan
8 | class Op
9 | attr_reader :step
10 | attr_reader :after
11 | attr_reader :location
12 | attr_reader :operation_type
13 | attr_reader :selections
14 | attr_reader :variables
15 | attr_reader :path
16 | attr_reader :if_type
17 | attr_reader :resolver
18 |
19 | def initialize(
20 | step:,
21 | after:,
22 | location:,
23 | operation_type:,
24 | selections:,
25 | variables: nil,
26 | path: nil,
27 | if_type: nil,
28 | resolver: nil
29 | )
30 | @step = step
31 | @after = after
32 | @location = location
33 | @operation_type = operation_type
34 | @selections = selections
35 | @variables = variables
36 | @path = path
37 | @if_type = if_type
38 | @resolver = resolver
39 | end
40 |
41 | def as_json
42 | {
43 | step: step,
44 | after: after,
45 | location: location,
46 | operation_type: operation_type,
47 | selections: selections,
48 | variables: variables,
49 | path: path,
50 | if_type: if_type,
51 | resolver: resolver
52 | }.tap(&:compact!)
53 | end
54 |
55 | def ==(other)
56 | step == other.step &&
57 | after == other.after &&
58 | location == other.location &&
59 | operation_type == other.operation_type &&
60 | selections == other.selections &&
61 | variables == other.variables &&
62 | path == other.path &&
63 | if_type == other.if_type &&
64 | resolver == other.resolver
65 | end
66 | end
67 |
68 | class << self
69 | def from_json(json)
70 | ops = json["ops"]
71 | ops = ops.map do |op|
72 | Op.new(
73 | step: op["step"],
74 | after: op["after"],
75 | location: op["location"],
76 | operation_type: op["operation_type"],
77 | selections: op["selections"],
78 | variables: op["variables"],
79 | path: op["path"],
80 | if_type: op["if_type"],
81 | resolver: op["resolver"],
82 | )
83 | end
84 | new(ops: ops)
85 | end
86 | end
87 |
88 | attr_reader :ops
89 |
90 | def initialize(ops: [])
91 | @ops = ops
92 | end
93 |
94 | def as_json
95 | { ops: @ops.map(&:as_json) }
96 | end
97 | end
98 | end
99 | end
--------------------------------------------------------------------------------
/docs/merged_types_apollo.md:
--------------------------------------------------------------------------------
1 | ## Merged types via Apollo Federation `_entities`
2 |
3 | The [Apollo Federation specification](https://www.apollographql.com/docs/federation/subgraph-spec/) defines a standard interface for accessing merged type variants across locations. Stitching can utilize a _subset_ of this interface to facilitate basic type merging; the full spec is NOT supported and therefore is not fully interchangable with an Apollo Gateway.
4 |
5 | To avoid confusion, using [basic resolver queries](../README.md#merged-type-resolver-queries) is recommended unless you specifically need to interact with a service built for an Apollo ecosystem. Even then, be wary that it does not exceed the supported spec by [using features that will not work](#federation-features-that-will-most-definitly-break).
6 |
7 | ### Supported spec
8 |
9 | The following subset of the federation spec is supported:
10 |
11 | - `@key(fields: "id")` (repeatable) specifies a key field for an object type.
12 | - `_Entity` is a union type that must contain all types that implement a `@key`.
13 | - `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
14 | - `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
15 |
16 | The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
17 |
18 | ```ruby
19 | products_schema = <<~GRAPHQL
20 | directive @key(fields: String!) repeatable on OBJECT
21 |
22 | type Product @key(fields: "id") {
23 | id: ID!
24 | name: String!
25 | }
26 |
27 | union _Entity = Product
28 | scalar _Any
29 |
30 | type Query {
31 | user(id: ID!): User
32 | _entities(representations: [_Any!]!): [_Entity]!
33 | }
34 | GRAPHQL
35 |
36 | catalog_schema = <<~GRAPHQL
37 | directive @key(fields: String!) repeatable on OBJECT
38 |
39 | type Product @key(fields: "id") {
40 | id: ID!
41 | price: Float!
42 | }
43 |
44 | union _Entity = Product
45 | scalar _Any
46 |
47 | type Query {
48 | _entities(representations: [_Any!]!): [_Entity]!
49 | }
50 | GRAPHQL
51 |
52 | client = GraphQL::Stitching::Client.new(locations: {
53 | products: {
54 | schema: GraphQL::Schema.from_definition(products_schema),
55 | executable: ...,
56 | },
57 | catalog: {
58 | schema: GraphQL::Schema.from_definition(catalog_schema),
59 | executable: ...,
60 | },
61 | })
62 | ```
63 |
64 | It's perfectly fine to mix and match schemas that implement an `_entities` query with schemas that implement `@stitch` directives; the protocols achieve the same result.
65 |
66 | ### Federation features that will most definitly break
67 |
68 | - `@external` fields will confuse the stitching query planner (as the fields aren't natively resolvable at the location).
69 | - `@requires` fields will not be sent any dependencies.
70 | - No support for Apollo composition directives.
71 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | # The test environment is used exclusively to run your application's
4 | # test suite. You never need to work with it otherwise. Remember that
5 | # your test database is "scratch space" for the test suite and is wiped
6 | # and recreated between test runs. Don't rely on the data there!
7 |
8 | Rails.application.configure do
9 | # Settings specified here will take precedence over those in config/application.rb.
10 |
11 | # While tests run files are not watched, reloading is not necessary.
12 | config.enable_reloading = false
13 |
14 | # Eager loading loads your entire application. When running a single test locally,
15 | # this is usually not necessary, and can slow down your test suite. However, it's
16 | # recommended that you enable it in continuous integration systems to ensure eager
17 | # loading is working properly before deploying your code.
18 | config.eager_load = ENV["CI"].present?
19 |
20 | # Configure public file server for tests with Cache-Control for performance.
21 | config.public_file_server.enabled = true
22 | config.public_file_server.headers = {
23 | "Cache-Control" => "public, max-age=#{1.hour.to_i}"
24 | }
25 |
26 | # Show full error reports and disable caching.
27 | config.consider_all_requests_local = true
28 | config.action_controller.perform_caching = false
29 | config.cache_store = :null_store
30 |
31 | # Render exception templates for rescuable exceptions and raise for other exceptions.
32 | config.action_dispatch.show_exceptions = :rescuable
33 |
34 | # Disable request forgery protection in test environment.
35 | config.action_controller.allow_forgery_protection = false
36 |
37 | # Store uploaded files on the local file system in a temporary directory.
38 | config.active_storage.service = :test
39 |
40 | config.action_mailer.perform_caching = false
41 |
42 | # Tell Action Mailer not to deliver emails to the real world.
43 | # The :test delivery method accumulates sent emails in the
44 | # ActionMailer::Base.deliveries array.
45 | config.action_mailer.delivery_method = :test
46 |
47 | # Print deprecation notices to the stderr.
48 | config.active_support.deprecation = :stderr
49 |
50 | # Raise exceptions for disallowed deprecations.
51 | config.active_support.disallowed_deprecation = :raise
52 |
53 | # Tell Active Support which deprecation messages to disallow.
54 | config.active_support.disallowed_deprecation_warnings = []
55 |
56 | # Raises error for missing translations.
57 | # config.i18n.raise_on_missing_translations = true
58 |
59 | # Annotate rendered view with file names.
60 | # config.action_view.annotate_rendered_view_with_filenames = true
61 |
62 | # Raise error when a before_action's only/except options reference missing actions
63 | config.action_controller.raise_on_missing_callback_actions = true
64 | end
65 |
--------------------------------------------------------------------------------
/examples/subscriptions/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # In the development environment your application's code is reloaded any time
7 | # it changes. This slows down response time but is perfect for development
8 | # since you don't have to restart the web server when you make code changes.
9 | config.enable_reloading = true
10 |
11 | # Do not eager load code on boot.
12 | config.eager_load = false
13 |
14 | # Show full error reports.
15 | config.consider_all_requests_local = true
16 |
17 | # Enable server timing
18 | config.server_timing = true
19 |
20 | # Enable/disable caching. By default caching is disabled.
21 | # Run rails dev:cache to toggle caching.
22 | if Rails.root.join("tmp/caching-dev.txt").exist?
23 | config.action_controller.perform_caching = true
24 | config.action_controller.enable_fragment_cache_logging = true
25 |
26 | config.cache_store = :memory_store
27 | config.public_file_server.headers = {
28 | "Cache-Control" => "public, max-age=#{2.days.to_i}"
29 | }
30 | else
31 | config.action_controller.perform_caching = false
32 |
33 | config.cache_store = :null_store
34 | end
35 |
36 | # Store uploaded files on the local file system (see config/storage.yml for options).
37 | config.active_storage.service = :local
38 |
39 | # Don't care if the mailer can't send.
40 | config.action_mailer.raise_delivery_errors = false
41 |
42 | config.action_mailer.perform_caching = false
43 |
44 | # Print deprecation notices to the Rails logger.
45 | config.active_support.deprecation = :log
46 |
47 | # Raise exceptions for disallowed deprecations.
48 | config.active_support.disallowed_deprecation = :raise
49 |
50 | # Tell Active Support which deprecation messages to disallow.
51 | config.active_support.disallowed_deprecation_warnings = []
52 |
53 | # Raise an error on page load if there are pending migrations.
54 | config.active_record.migration_error = :page_load
55 |
56 | # Highlight code that triggered database queries in logs.
57 | config.active_record.verbose_query_logs = true
58 |
59 | # Highlight code that enqueued background job in logs.
60 | config.active_job.verbose_enqueue_logs = true
61 |
62 |
63 | # Raises error for missing translations.
64 | # config.i18n.raise_on_missing_translations = true
65 |
66 | # Annotate rendered view with file names.
67 | # config.action_view.annotate_rendered_view_with_filenames = true
68 |
69 | # Uncomment if you wish to allow Action Cable access from any origin.
70 | # config.action_cable.disable_request_forgery_protection = true
71 |
72 | # Raise error when a before_action's only/except options reference missing actions
73 | config.action_controller.raise_on_missing_callback_actions = true
74 | end
75 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_object_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging objects' do
6 |
7 | def test_merges_object_descriptions
8 | a = %{"""a""" type Test { field: String } type Query { test:Test }}
9 | b = %{"""b""" type Test { field: String } type Query { test:Test }}
10 |
11 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
12 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
13 | })
14 |
15 | assert_equal "a/b", supergraph.schema.types["Test"].description
16 | end
17 |
18 | def test_merges_object_directives
19 | a = %|
20 | directive @fizzbuzz(arg: String!) on OBJECT
21 | type Test @fizzbuzz(arg: "a") { field: String }
22 | type Query { test:Test }
23 | |
24 |
25 | b = %|
26 | directive @fizzbuzz(arg: String!) on OBJECT
27 | type Test @fizzbuzz(arg: "b") { field: String }
28 | type Query { test:Test }
29 | |
30 |
31 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
32 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
33 | })
34 |
35 | assert_equal "a/b", supergraph.schema.types["Test"].directives.first.arguments.keyword_arguments[:arg]
36 | end
37 |
38 | def test_merges_interface_memberships
39 | a = %{interface A { id:ID } type C implements A { id:ID } type Query { c:C }}
40 | b = %{interface B { id:ID } type C implements B { id:ID } type Query { c:C }}
41 |
42 | supergraph = compose_definitions({ "a" => a, "b" => b })
43 |
44 | assert_equal ["A", "B"], supergraph.schema.types["C"].interfaces.map(&:graphql_name).sort
45 | end
46 |
47 | MOVIES_SCHEMA = %{
48 | type Movie {
49 | id: ID!
50 | title: String!
51 | }
52 |
53 | type Genre {
54 | name: String!
55 | }
56 |
57 | type Query {
58 | movie(id: ID!): Movie @stitch(key: "id")
59 | genre: Genre!
60 | }
61 | }
62 |
63 | SHOWTIMES_SCHEMA = %{
64 | type Movie {
65 | id: ID!
66 | title: String
67 | showtimes: [Showtime!]!
68 | }
69 |
70 | type Showtime {
71 | time: String!
72 | }
73 |
74 | type Query {
75 | movie(id: ID!): Movie @stitch(key: "id")
76 | showtime: Showtime!
77 | }
78 | }
79 |
80 | def test_combines_objects_and_their_fields
81 | supergraph = compose_definitions({
82 | "movies" => MOVIES_SCHEMA,
83 | "showtimes" => SHOWTIMES_SCHEMA,
84 | })
85 |
86 | schema_objects = extract_types_of_kind(supergraph.schema, "OBJECT")
87 | assert_equal ["Genre", "Movie", "Query", "Showtime"], schema_objects.map(&:graphql_name).sort
88 | assert_equal ["id", "showtimes", "title"], supergraph.schema.types["Movie"].fields.keys.sort
89 | assert_equal ["genre", "movie", "showtime"], supergraph.schema.types["Query"].fields.keys.sort
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/executor/root_source.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Executor
5 | class RootSource < GraphQL::Dataloader::Source
6 | def initialize(executor, location)
7 | @executor = executor
8 | @location = location
9 | end
10 |
11 | def fetch(ops)
12 | op = ops.first # There should only ever be one per location at a time
13 |
14 | query_document = build_document(
15 | op,
16 | @executor.request.operation_name,
17 | @executor.request.operation_directives,
18 | )
19 | query_variables = @executor.request.variables.slice(*op.variables.each_key)
20 | result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
21 | @executor.query_count += 1
22 |
23 | if result["data"]
24 | unless op.path.empty?
25 | # Nested root scopes must expand their pathed origin set
26 | origin_set = op.path.reduce([@executor.data]) do |set, ns|
27 | set.flat_map { |obj| obj && obj[ns] }.tap(&:compact!)
28 | end
29 |
30 | origin_set.each { _1.merge!(result["data"]) }
31 | else
32 | # Actual root scopes merge directly into results data
33 | @executor.data.merge!(result["data"])
34 | end
35 | end
36 |
37 | if result["errors"]&.any?
38 | @executor.errors.concat(format_errors!(result["errors"], op.path))
39 | end
40 |
41 | ops.map(&:step)
42 | end
43 |
44 | # Builds root source documents
45 | # "query MyOperation_1($var:VarType) { rootSelections ... }"
46 | def build_document(op, operation_name = nil, operation_directives = nil)
47 | doc_buffer = String.new
48 | doc_buffer << op.operation_type
49 |
50 | if operation_name
51 | doc_buffer << " " << operation_name << "_" << op.step.to_s
52 | end
53 |
54 | unless op.variables.empty?
55 | doc_buffer << "("
56 | op.variables.each_with_index do |(k, v), i|
57 | doc_buffer << "," unless i.zero?
58 | doc_buffer << "$" << k << ":" << v
59 | end
60 | doc_buffer << ")"
61 | end
62 |
63 | if operation_directives
64 | doc_buffer << " " << operation_directives << " "
65 | end
66 |
67 | doc_buffer << op.selections
68 | doc_buffer
69 | end
70 |
71 | # Format response errors without a document location (because it won't match the request doc),
72 | # and prepend any insertion path for the scope into error paths.
73 | def format_errors!(errors, path)
74 | errors.each do |err|
75 | err.delete("locations")
76 | err["path"].unshift(*path) if err["path"] && !path.empty?
77 | end
78 | errors
79 | end
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/request/skip_include.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Request
5 | # Faster implementation of an AST visitor for prerendering
6 | # @skip and @include conditional directives into a document.
7 | # This avoids unnecessary planning steps, and prepares result shaping.
8 | # @api private
9 | class SkipInclude
10 | class << self
11 | def render(document, variables)
12 | changed = false
13 | definitions = document.definitions.map do |original_definition|
14 | definition = render_node(original_definition, variables)
15 | changed ||= definition.object_id != original_definition.object_id
16 | definition
17 | end
18 |
19 | return document unless changed
20 |
21 | document = document.merge(definitions: definitions)
22 | yield(document) if block_given?
23 | document
24 | end
25 |
26 | private
27 |
28 | def render_node(parent_node, variables)
29 | changed = false
30 | filtered_selections = parent_node.selections.filter_map do |original_node|
31 | node = prune_node(original_node, variables)
32 | if node.nil?
33 | changed = true
34 | next nil
35 | end
36 |
37 | node = render_node(node, variables) unless node.selections.empty?
38 | changed ||= node.object_id != original_node.object_id
39 | node
40 | end
41 |
42 | if filtered_selections.none?
43 | filtered_selections << TypeResolver::TYPENAME_EXPORT_NODE
44 | end
45 |
46 | if changed
47 | parent_node.merge(selections: filtered_selections)
48 | else
49 | parent_node
50 | end
51 | end
52 |
53 | def prune_node(node, variables)
54 | return node if node.directives.empty?
55 |
56 | delete_node = false
57 | filtered_directives = node.directives.reject do |directive|
58 | if directive.name == "skip"
59 | delete_node = assess_condition(directive.arguments.first, variables)
60 | true
61 | elsif directive.name == "include"
62 | delete_node = !assess_condition(directive.arguments.first, variables)
63 | true
64 | end
65 | end
66 |
67 | if delete_node
68 | nil
69 | elsif filtered_directives.length != node.directives.length
70 | node.merge(directives: filtered_directives)
71 | else
72 | node
73 | end
74 | end
75 |
76 | def assess_condition(arg, variables)
77 | if arg.value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
78 | variables[arg.value.name] || variables[arg.value.name.to_sym]
79 | else
80 | arg.value
81 | end
82 | end
83 | end
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/docs/error_handling.md:
--------------------------------------------------------------------------------
1 | ## Error handling
2 |
3 | Failed stitching requests can be tricky to debug because it's not always obvious where the actual error occured. Error handling helps surface issues and make them easier to locate.
4 |
5 | ### Supergraph errors
6 |
7 | When exceptions happen while executing requests within the stitching layer, they will be rescued by the stitching client and trigger an `on_error` hook. You should add your stack's error reporting here and return a formatted error message to appear in [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) for the request.
8 |
9 | ```ruby
10 | client = GraphQL::Stitching::Client.new(locations: { ... })
11 | client.on_error do |request, err|
12 | # log the error
13 | Bugsnag.notify(err)
14 |
15 | # return a formatted message for the public response
16 | "Whoops, please contact support abount request '#{request.context[:request_id]}'"
17 | end
18 |
19 | # Result:
20 | # { "errors" => [{ "message" => "Whoops, please contact support abount request '12345'" }] }
21 | ```
22 |
23 | ### Subgraph errors
24 |
25 | When subgraph resources produce errors, it's very important that each error provides a proper `path` indicating the field associated with the error. Most major GraphQL implementations, including GraphQL Ruby, [do this automatically](https://graphql-ruby.org/errors/overview.html):
26 |
27 | ```json
28 | {
29 | "data": { "shop": { "product": null } },
30 | "errors": [{
31 | "message": "Record not found.",
32 | "path": ["shop", "product"]
33 | }]
34 | }
35 | ```
36 |
37 | Be careful when resolving lists, particularly for merged type resolvers. Lists should only error out specific array positions rather than the entire array result whenever possible, for example:
38 |
39 | ```ruby
40 | def products
41 | [
42 | { id: "1" },
43 | GraphQL::ExecutionError.new("Not found"),
44 | { id: "3" },
45 | ]
46 | end
47 | ```
48 |
49 | These cases should report corresponding errors pathed down to the list index without affecting other successful results in the list:
50 |
51 | ```json
52 | {
53 | "data": {
54 | "products": [{ "id": "1" }, null, { "id": "3" }]
55 | },
56 | "errors": [{
57 | "message": "Record not found.",
58 | "path": ["products", 1]
59 | }]
60 | }
61 | ```
62 |
63 | ### Merging subgraph errors
64 |
65 | All [spec GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) returned from subgraph queries will flow through the stitched request and into the final result. Formatting these errors follows one of two strategies:
66 |
67 | 1. **Direct passthrough**, where subgraph errors are returned directly in the merged response without modification. This strategy is used for errors without a `path` (ie: "base" errors), and errors pathed to root fields.
68 |
69 | 2. **Mapped passthrough**, where the `path` attribute of a subgraph error is remapped to an insertion point in the supergraph request. This strategy is used when a merged type resolver returns an error for an object in a lower-level position of the supergraph document.
70 |
--------------------------------------------------------------------------------
/test/graphql/stitching/planner/plan_delegations_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::Planner, delegation strategies" do
6 |
7 | # Fields a + b select together
8 | alpha_sdl = %|
9 | type Widget { id: ID! a: String! b: String! }
10 | type Query { alpha(id: ID!): Widget @stitch(key: "id") }
11 | |
12 |
13 | bravo_sdl = %|
14 | type Widget { id: ID! a: String! b: String! }
15 | type Query { bravo(id: ID!): Widget @stitch(key: "id") }
16 | |
17 |
18 | # Field c is unique to one location
19 | # Field d joins location already used for c
20 | charlie_sdl = %|
21 | type Widget { id: ID! c: String! d: String! }
22 | type Query { charlie(id: ID!): Widget @stitch(key: "id") }
23 | |
24 |
25 | delta_sdl = %|
26 | type Widget { id: ID! d: String! e: String! }
27 | type Query { delta(id: ID!): Widget @stitch(key: "id") }
28 | |
29 |
30 | # Fields e + f go to highest availability
31 | echo_sdl = %|
32 | type Widget { id: ID! d: String! e: String! f: String! }
33 | type Query { echo(id: ID!): Widget @stitch(key: "id") }
34 | |
35 |
36 | foxtrot_sdl = %|
37 | type Widget { id: ID! d: String! f: String! }
38 | type Query { foxtrot(id: ID!): Widget @stitch(key: "id") }
39 | |
40 |
41 | SUPERGRAPH = compose_definitions({
42 | "alpha" => alpha_sdl,
43 | "bravo" => bravo_sdl,
44 | "charlie" => charlie_sdl,
45 | "delta" => delta_sdl,
46 | "echo" => echo_sdl,
47 | "foxtrot" => foxtrot_sdl,
48 | })
49 |
50 | def test_delegates_common_fields_to_current_routing_location
51 | plan1 = GraphQL::Stitching::Request.new(
52 | SUPERGRAPH,
53 | %|query { alpha(id: "1") { a b } }|,
54 | ).plan
55 |
56 | op1 = plan1.ops[0]
57 | assert_equal "alpha", op1.location
58 | assert_equal %|{ alpha(id: "1") { a b } }|, op1.selections
59 |
60 | plan2 = GraphQL::Stitching::Request.new(
61 | SUPERGRAPH,
62 | %|query { bravo(id: "1") { a b } }|,
63 | ).plan
64 |
65 | op2 = plan2.ops[0]
66 | assert_equal "bravo", op2.location
67 | assert_equal %|{ bravo(id: "1") { a b } }|, op2.selections
68 | end
69 |
70 | def test_delegates_remote_selections_by_unique_location_then_used_location_then_highest_availability
71 | plan = GraphQL::Stitching::Request.new(
72 | SUPERGRAPH,
73 | %|query { alpha(id: "1") { a b c d e f } }|,
74 | ).plan
75 |
76 | assert_equal 3, plan.ops.length
77 |
78 | first = plan.ops[0]
79 | assert_equal "alpha", first.location
80 | assert_equal %|{ alpha(id: "1") { a b _export_id: id _export___typename: __typename } }|, first.selections
81 |
82 | second = plan.ops[1]
83 | assert_equal "charlie", second.location
84 | assert_equal "{ c d }", second.selections
85 | assert_equal first.step, second.after
86 |
87 | third = plan.ops[2]
88 | assert_equal "echo", third.location
89 | assert_equal "{ e f }", third.selections
90 | assert_equal first.step, third.after
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/docs/query_planning.md:
--------------------------------------------------------------------------------
1 | ## Query Planning
2 |
3 |
4 | ### Root selection routing
5 |
6 | It's okay if root field names are repeated across locations. The entrypoint location will be used when routing root selections:
7 |
8 | ```graphql
9 | # -- Location A
10 |
11 | type Movie {
12 | id: String!
13 | rating: Int!
14 | }
15 |
16 | type Query {
17 | movie(id: ID!): Movie @stitch(key: "id") # << set as root entrypoint
18 | }
19 |
20 | # -- Location B
21 |
22 | type Movie {
23 | id: String!
24 | reviews: [String!]!
25 | }
26 |
27 | type Query {
28 | movie(id: ID!): Movie @stitch(key: "id")
29 | }
30 |
31 | # -- Request
32 |
33 | query {
34 | movie(id: "23") { id } # routes to Location A
35 | }
36 | ```
37 |
38 | Note that primary location routing _only_ applies to selections in the root scope. If the `Query` type appears again lower in the graph, then its fields are resolved as normal object fields outside of root context, for example:
39 |
40 | ```graphql
41 | schema {
42 | query: Query # << root query, uses primary locations
43 | }
44 |
45 | type Query {
46 | subquery: Query # << subquery, acts as a normal object type
47 | }
48 | ```
49 |
50 | Also note that stitching queries (denoted by the `@stitch` directive) are completely separate from field routing concerns. A `@stitch` directive establishes a contract for resolving a given type in a given location. This contract is always used to collect stitching data, regardless of how request routing selected the location for use.
51 |
52 | ### Field selection routing
53 |
54 | Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
55 |
56 | ```graphql
57 | # -- Location A
58 |
59 | type Movie {
60 | id: String!
61 | title: String! # shared
62 | rating: Int!
63 | }
64 |
65 | type Query {
66 | movieA(id: ID!): Movie @stitch(key: "id")
67 | }
68 |
69 | # -- Location B
70 |
71 | type Movie {
72 | id: String!
73 | title: String! # shared
74 | reviews: [String!]!
75 | }
76 |
77 | type Query {
78 | movieB(id: ID!): Movie @stitch(key: "id")
79 | }
80 | ```
81 |
82 | When planning a request, field selections always attempt to use the current routing location that originates from the selection root, for example:
83 |
84 | ```graphql
85 | query GetTitleFromA {
86 | movieA(id: "23") { # <- enter via Location A
87 | title # <- source from Location A
88 | }
89 | }
90 |
91 | query GetTitleFromB {
92 | movieB(id: "23") { # <- enter via Location B
93 | title # <- source from Location B
94 | }
95 | }
96 | ```
97 |
98 | Field selections that are NOT available in the current routing location delegate to new locations as follows:
99 |
100 | 1. Fields with only one location automatically use that location.
101 | 2. Fields with multiple locations attempt to use a location added during step-1.
102 | 3. Any remaining fields pick a location based on their highest availability among locations.
103 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/executor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "json"
4 | require_relative "executor/root_source"
5 | require_relative "executor/type_resolver_source"
6 | require_relative "executor/shaper"
7 |
8 | module GraphQL
9 | module Stitching
10 | # Executor handles executing upon a planned request.
11 | # All planned steps are initiated, their results merged,
12 | # and loaded keys are collected for batching subsequent steps.
13 | # Final execution results are then shaped to match the request selection.
14 | class Executor
15 | # @return [Request] the stitching request to execute.
16 | attr_reader :request
17 |
18 | # @return [Hash] an aggregate data payload to return.
19 | attr_reader :data
20 |
21 | # @return [Array] aggregate GraphQL errors to return.
22 | attr_reader :errors
23 |
24 | # @return [Integer] tally of queries performed while executing.
25 | attr_accessor :query_count
26 |
27 | # Builds a new executor.
28 | # @param request [Request] the stitching request to execute.
29 | # @param nonblocking [Boolean] specifies if the dataloader should use async concurrency.
30 | def initialize(request, data: {}, errors: [], after: Planner::ROOT_INDEX, nonblocking: false)
31 | @request = request
32 | @data = data
33 | @errors = errors
34 | @after = after
35 | @query_count = 0
36 | @exec_cycles = 0
37 | @dataloader = GraphQL::Dataloader.new(nonblocking: nonblocking)
38 | end
39 |
40 | def perform(raw: false)
41 | exec!([@after])
42 | result = {}
43 |
44 | if @data && @data.length > 0
45 | result["data"] = raw ? @data : Shaper.new(@request).perform!(@data)
46 | end
47 |
48 | if @errors.length > 0
49 | result["errors"] = @errors
50 | end
51 |
52 | GraphQL::Query::Result.new(query: @request, values: result)
53 | end
54 |
55 | private
56 |
57 | def exec!(next_steps)
58 | if @exec_cycles > @request.plan.ops.length
59 | # sanity check... if we've exceeded queue size, then something went wrong.
60 | raise StitchingError, "Too many execution requests attempted."
61 | end
62 |
63 | @dataloader.append_job do
64 | tasks = @request.plan
65 | .ops
66 | .select { next_steps.include?(_1.after) }
67 | .group_by { [_1.location, _1.resolver.nil?] }
68 | .map do |(location, root_source), ops|
69 | source_class = root_source ? RootSource : TypeResolverSource
70 | @dataloader.with(source_class, self, location).request_all(ops)
71 | end
72 |
73 | tasks.each(&method(:exec_task))
74 | end
75 |
76 | @exec_cycles += 1
77 | @dataloader.run
78 | end
79 |
80 | def exec_task(task)
81 | next_steps = task.load.tap(&:compact!)
82 | exec!(next_steps) unless next_steps.empty?
83 | end
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/test/graphql/stitching/request/skip_include_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::Request::SkipInclude" do
6 | def test_omits_statically_skipped_nodes
7 | render_skip_include "query {
8 | a {
9 | a
10 | b @skip(if: true)
11 | c @include(if: false)
12 | }
13 | b @skip(if: true)
14 | c @include(if: false)
15 | }"
16 |
17 | assert changed?
18 | assert_result "query {
19 | a { a }
20 | }"
21 | end
22 |
23 | def test_removes_conditional_directives_from_kept_nodes
24 | render_skip_include "query {
25 | a {
26 | a
27 | b @skip(if: false)
28 | c @include(if: true)
29 | }
30 | b @skip(if: false)
31 | c @include(if: true)
32 | }"
33 |
34 | assert changed?
35 | assert_result "query {
36 | a { a b c }
37 | b
38 | c
39 | }"
40 | end
41 |
42 | def test_omits_nodes_skipped_using_variables
43 | render_skip_include "query($skip: Boolean!, $include: Boolean!) {
44 | a {
45 | a
46 | b @skip(if: $skip)
47 | c @include(if: $include)
48 | }
49 | b @skip(if: $include)
50 | c @include(if: $skip)
51 | }", {
52 | "skip" => true,
53 | "include" => false,
54 | }
55 |
56 | assert changed?
57 | assert_result "query($skip: Boolean!, $include: Boolean!) {
58 | a { a }
59 | b
60 | c
61 | }"
62 | end
63 |
64 | def test_variables_may_reference_symbol_keys
65 | render_skip_include "query($skip: Boolean!, $include: Boolean!) {
66 | a {
67 | a
68 | b @skip(if: $skip)
69 | c @include(if: $include)
70 | }
71 | }", {
72 | skip: true,
73 | include: false,
74 | }
75 |
76 | assert changed?
77 | assert_result "query($skip: Boolean!, $include: Boolean!) {
78 | a { a }
79 | }"
80 | end
81 |
82 | def test_omitted_nodes_leaving_an_empty_scope_add_typename
83 | render_skip_include "query {
84 | a {
85 | b @skip(if: true)
86 | c @include(if: false)
87 | }
88 | }"
89 |
90 | assert changed?
91 | assert_result "query {
92 | a { _export___typename: __typename }
93 | }"
94 | end
95 |
96 | def test_lacking_conditionals_produces_no_changes
97 | render_skip_include "query {
98 | a { b c }
99 | }"
100 |
101 | assert !changed?
102 | end
103 |
104 | private
105 |
106 | def render_skip_include(source, variables = {})
107 | @source = source
108 | @changed = false
109 | @result = GraphQL::Stitching::Request::SkipInclude.render(GraphQL.parse(@source), variables) do
110 | @changed = true
111 | end
112 | end
113 |
114 | def assert_result(result)
115 | assert_equal squish_string(result), squish_string(GraphQL::Language::Printer.new.print(@result))
116 | end
117 |
118 | def changed?
119 | @changed
120 | end
121 | end
122 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/composer/type_resolver_config.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Composer
5 | class TypeResolverConfig
6 | ENTITY_TYPENAME = "_Entity"
7 | ENTITIES_QUERY = "_entities"
8 |
9 | class << self
10 | def extract_directive_assignments(schema, location, assignments)
11 | return EMPTY_OBJECT unless assignments && !assignments.empty?
12 |
13 | assignments.each_with_object({}) do |kwargs, memo|
14 | type = kwargs[:parent_type_name] ? schema.get_type(kwargs[:parent_type_name]) : schema.query
15 | raise CompositionError, "Invalid stitch directive type `#{kwargs[:parent_type_name]}`" unless type
16 |
17 | field = type.get_field(kwargs[:field_name])
18 | raise CompositionError, "Invalid stitch directive field `#{kwargs[:field_name]}`" unless field
19 |
20 | field_path = "#{location}.#{field.name}"
21 | memo[field_path] ||= []
22 | memo[field_path] << from_kwargs(kwargs)
23 | end
24 | end
25 |
26 | def extract_federation_entities(schema, location)
27 | return EMPTY_OBJECT unless federation_entities_schema?(schema)
28 |
29 | schema.possible_types(schema.get_type(ENTITY_TYPENAME)).each_with_object({}) do |entity_type, memo|
30 | entity_type.directives.each do |directive|
31 | next unless directive.graphql_name == "key"
32 |
33 | key = TypeResolver.parse_key(directive.arguments.keyword_arguments.fetch(:fields))
34 | key_fields = key.map { "#{_1.name}: $.#{_1.name}" }
35 | field_path = "#{location}._entities"
36 |
37 | memo[field_path] ||= []
38 | memo[field_path] << new(
39 | key: key.to_definition,
40 | type_name: entity_type.graphql_name,
41 | arguments: "representations: { #{key_fields.join(", ")}, #{TYPENAME}: $.#{TYPENAME} }",
42 | )
43 | end
44 | end
45 | end
46 |
47 | def from_kwargs(kwargs)
48 | new(
49 | key: kwargs[:key],
50 | type_name: kwargs[:type_name] || kwargs[:typeName],
51 | arguments: kwargs[:arguments],
52 | )
53 | end
54 |
55 | private
56 |
57 | def federation_entities_schema?(schema)
58 | entity_type = schema.get_type(ENTITY_TYPENAME)
59 | entities_query = schema.query.get_field(ENTITIES_QUERY)
60 | entity_type &&
61 | entity_type.kind.union? &&
62 | entities_query &&
63 | entities_query.arguments["representations"] &&
64 | entities_query.type.list? &&
65 | entities_query.type.unwrap == entity_type
66 | end
67 | end
68 |
69 | attr_reader :key, :type_name, :arguments
70 |
71 | def initialize(key:, type_name:, arguments: nil)
72 | @key = key
73 | @type_name = type_name
74 | @arguments = arguments
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/configuration_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, configuration' do
6 |
7 | def test_perform_with_executable_config
8 | executable = Proc.new { true }
9 | supergraph = GraphQL::Stitching::Composer.new.perform({
10 | storefronts: {
11 | schema: GraphQL::Schema.from_definition("type Query { ping: String! }"),
12 | executable: executable,
13 | }
14 | })
15 |
16 | assert_equal executable, supergraph.executables["storefronts"]
17 | end
18 |
19 | def test_perform_with_static_resolver_config
20 | alpha = %|
21 | type Product { id: ID! name: String! }
22 | type Query { productA(id: ID!): Product }
23 | |
24 |
25 | bravo = %|
26 | type Product { id: ID! price: Float! }
27 | type Query { productB(key: ID, other: String): Product }
28 | |
29 |
30 | supergraph = GraphQL::Stitching::Composer.new.perform({
31 | alpha: {
32 | schema: GraphQL::Schema.from_definition(alpha),
33 | stitch: [
34 | { field_name: "productA", key: "id" },
35 | ]
36 | },
37 | bravo: {
38 | schema: GraphQL::Schema.from_definition(bravo),
39 | stitch: [
40 | { field_name: "productB", key: "id", arguments: "key: $.id" },
41 | ]
42 | }
43 | })
44 |
45 | expected_resolvers = {
46 | "Product" => [
47 | GraphQL::Stitching::TypeResolver.new(
48 | location: "alpha",
49 | type_name: "Product",
50 | list: false,
51 | field: "productA",
52 | key: GraphQL::Stitching::TypeResolver.parse_key("id"),
53 | arguments: GraphQL::Stitching::TypeResolver.parse_arguments_with_type_defs("id: $.id", "id: ID"),
54 | ),
55 | GraphQL::Stitching::TypeResolver.new(
56 | location: "bravo",
57 | type_name: "Product",
58 | list: false,
59 | field: "productB",
60 | key: GraphQL::Stitching::TypeResolver.parse_key("id"),
61 | arguments: GraphQL::Stitching::TypeResolver.parse_arguments_with_type_defs("key: $.id", "key: ID"),
62 | ),
63 | ]
64 | }
65 |
66 | assert_equal expected_resolvers, supergraph.resolvers
67 | end
68 |
69 | def test_perform_federation_schema
70 | schema = %|
71 | directive @key(fields: String!) repeatable on OBJECT
72 | type Product @key(fields: "id sku") { id: ID! sku: String! price: Float! }
73 | union _Entity = Product
74 | scalar _Any
75 | type Query { _entities(representations: [_Any!]!): [_Entity]! }
76 | |
77 |
78 | configs = GraphQL::Stitching::Composer::TypeResolverConfig.extract_federation_entities(
79 | GraphQL::Schema.from_definition(schema),
80 | "alpha",
81 | )
82 |
83 | resolver_config = configs["alpha._entities"].first
84 | assert_equal "Product", resolver_config.type_name
85 | assert_equal "id sku", resolver_config.key
86 | assert_equal "representations: { id: $.id, sku: $.sku, __typename: $.__typename }", resolver_config.arguments
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/composer/validate_interfaces.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Composer
5 | class ValidateInterfaces < BaseValidator
6 | # For each composed interface, check the interface against each possible type
7 | # to assure that intersecting fields have compatible types, structures, and nullability.
8 | # Verifies compatibility of types that inherit interface contracts through merging.
9 | def perform(supergraph, composer)
10 | supergraph.schema.types.each do |type_name, interface_type|
11 | next unless interface_type.kind.interface?
12 |
13 | supergraph.schema.possible_types(interface_type).each do |possible_type|
14 | interface_type.fields.each do |field_name, interface_field|
15 | # graphql-ruby will dynamically apply interface fields on a type implementation,
16 | # so check the delegation map to assure that all materialized fields have resolver locations.
17 | unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any?
18 | raise ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
19 | "which is required by interface #{interface_type.graphql_name}."
20 | end
21 |
22 | intersecting_field = possible_type.fields[field_name]
23 | interface_type_structure = Util.flatten_type_structure(interface_field.type)
24 | possible_type_structure = Util.flatten_type_structure(intersecting_field.type)
25 |
26 | if possible_type_structure.length != interface_type_structure.length
27 | raise ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
28 | "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
29 | end
30 |
31 | interface_type_structure.each_with_index do |interface_struct, index|
32 | possible_struct = possible_type_structure[index]
33 |
34 | if possible_struct.name != interface_struct.name
35 | raise ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
36 | "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
37 | end
38 |
39 | if possible_struct.null? && interface_struct.non_null?
40 | raise ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
41 | "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
42 | end
43 | end
44 | end
45 | end
46 | end
47 | end
48 |
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_interface_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging interfaces' do
6 |
7 | def test_merges_interface_descriptions
8 | a = %{"""a""" interface Test { field: String } type Query { test:Test }}
9 | b = %{"""b""" interface Test { field: String } type Query { test:Test }}
10 |
11 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
12 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
13 | })
14 |
15 | assert_equal "a/b", supergraph.schema.types["Test"].description
16 | end
17 |
18 | def test_merges_interface_directives
19 | a = %|
20 | directive @fizzbuzz(arg: String!) on INTERFACE
21 | interface Test @fizzbuzz(arg: "a") { field: String }
22 | type Query { test:Test }
23 | |
24 |
25 | b = %|
26 | directive @fizzbuzz(arg: String!) on INTERFACE
27 | interface Test @fizzbuzz(arg: "b") { field: String }
28 | type Query { test:Test }
29 | |
30 |
31 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
32 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
33 | })
34 |
35 | assert_equal "a/b", supergraph.schema.types["Test"].directives.first.arguments.keyword_arguments[:arg]
36 | end
37 |
38 | def test_merges_single_interface_memberships
39 | a = %{interface A { id:ID } type C implements A { id:ID } type Query { c:C }}
40 | b = %{interface B { id:ID } type C implements B { id:ID } type Query { c:C }}
41 |
42 | supergraph = compose_definitions({ "a" => a, "b" => b })
43 |
44 | assert_equal ["A", "B"], supergraph.schema.types["C"].interfaces.map(&:graphql_name).sort
45 | assert supergraph.schema.to_definition
46 | end
47 |
48 | def test_merges_inherited_interface_memberships
49 | skip unless minimum_graphql_version?("2.0.3")
50 |
51 | a = %{interface A { id:ID } interface AA implements A { id:ID } type C implements AA { id:ID } type Query { c:C }}
52 | b = %{interface B { id:ID } interface BB implements B { id:ID } type C implements BB { id:ID } type Query { c:C }}
53 |
54 | supergraph = compose_definitions({ "a" => a, "b" => b })
55 |
56 | assert_equal ["A"], supergraph.schema.types["AA"].interfaces.map(&:graphql_name).sort
57 | assert_equal ["B"], supergraph.schema.types["BB"].interfaces.map(&:graphql_name).sort
58 | assert_equal ["A", "AA", "B", "BB"], supergraph.schema.types["C"].interfaces.map(&:graphql_name).sort
59 | assert supergraph.schema.to_definition
60 | end
61 |
62 | def test_merges_interface_fields
63 | a = %{
64 | interface I { id:ID! name:String }
65 | type T implements I { id:ID! name:String }
66 | type Query { t(id:ID!):T @stitch(key: "id") }
67 | }
68 | b = %{
69 | interface I { id:ID! code:String }
70 | type T implements I { id:ID! code:String }
71 | type Query { t(id:ID!):T @stitch(key: "id") }
72 | }
73 |
74 | supergraph = compose_definitions({ "a" => a, "b" => b })
75 |
76 | assert_equal ["code", "id", "name"], supergraph.schema.get_type("I").fields.keys.sort
77 | assert_equal ["code", "id", "name"], supergraph.schema.get_type("T").fields.keys.sort
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/examples/subscriptions/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'bundle' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require "rubygems"
12 |
13 | m = Module.new do
14 | module_function
15 |
16 | def invoked_as_script?
17 | File.expand_path($0) == File.expand_path(__FILE__)
18 | end
19 |
20 | def env_var_version
21 | ENV["BUNDLER_VERSION"]
22 | end
23 |
24 | def cli_arg_version
25 | return unless invoked_as_script? # don't want to hijack other binstubs
26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
27 | bundler_version = nil
28 | update_index = nil
29 | ARGV.each_with_index do |a, i|
30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
31 | bundler_version = a
32 | end
33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
34 | bundler_version = $1
35 | update_index = i
36 | end
37 | bundler_version
38 | end
39 |
40 | def gemfile
41 | gemfile = ENV["BUNDLE_GEMFILE"]
42 | return gemfile if gemfile && !gemfile.empty?
43 |
44 | File.expand_path("../Gemfile", __dir__)
45 | end
46 |
47 | def lockfile
48 | lockfile =
49 | case File.basename(gemfile)
50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
51 | else "#{gemfile}.lock"
52 | end
53 | File.expand_path(lockfile)
54 | end
55 |
56 | def lockfile_version
57 | return unless File.file?(lockfile)
58 | lockfile_contents = File.read(lockfile)
59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
60 | Regexp.last_match(1)
61 | end
62 |
63 | def bundler_requirement
64 | @bundler_requirement ||=
65 | env_var_version ||
66 | cli_arg_version ||
67 | bundler_requirement_for(lockfile_version)
68 | end
69 |
70 | def bundler_requirement_for(version)
71 | return "#{Gem::Requirement.default}.a" unless version
72 |
73 | bundler_gem_version = Gem::Version.new(version)
74 |
75 | bundler_gem_version.approximate_recommendation
76 | end
77 |
78 | def load_bundler!
79 | ENV["BUNDLE_GEMFILE"] ||= gemfile
80 |
81 | activate_bundler
82 | end
83 |
84 | def activate_bundler
85 | gem_error = activation_error_handling do
86 | gem "bundler", bundler_requirement
87 | end
88 | return if gem_error.nil?
89 | require_error = activation_error_handling do
90 | require "bundler/version"
91 | end
92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
94 | exit 42
95 | end
96 |
97 | def activation_error_handling
98 | yield
99 | nil
100 | rescue StandardError, LoadError => e
101 | e
102 | end
103 | end
104 |
105 | m.load_bundler!
106 |
107 | if m.invoked_as_script?
108 | load Gem.bin_path("bundler", "bundle")
109 | end
110 |
--------------------------------------------------------------------------------
/test/graphql/stitching/http_executable_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::HttpExecutable" do
6 |
7 | class UploadSchema < GraphQL::Schema
8 | class Upload < GraphQL::Schema::Scalar
9 | graphql_name "Upload"
10 | end
11 |
12 | class FileInput < GraphQL::Schema::InputObject
13 | graphql_name "FileInput"
14 |
15 | argument :file, Upload, required: false
16 | argument :files, [Upload], required: false
17 | argument :deep, [[Upload]], required: false
18 | argument :nested, FileInput, required: false
19 | end
20 |
21 | class Root < GraphQL::Schema::Object
22 | field :upload, Boolean, null: true do
23 | argument :input, FileInput, required: true
24 | end
25 | field :uploads, Boolean, null: true do
26 | argument :inputs, [FileInput], required: true
27 | end
28 | end
29 |
30 | query Root
31 | end
32 |
33 | DummyFile = Struct.new(:tempfile)
34 |
35 | def setup
36 | @supergraph = GraphQL::Stitching::Supergraph.new(
37 | schema: Class.new(UploadSchema),
38 | )
39 | end
40 |
41 | def test_extract_multipart_form
42 | file1 = DummyFile.new("A")
43 | file2 = DummyFile.new("B")
44 | document = %|
45 | mutation($input: FileInput!, $inputs: [FileInput]!) {
46 | upload(input: $input)
47 | uploads(inputs: $inputs)
48 | }
49 | |
50 | variables = {
51 | "input" => {
52 | "file" => file1,
53 | "files" => [file1, file2],
54 | },
55 | "inputs" => [{
56 | "file" => file1,
57 | "files" => [file1, file2],
58 | },{
59 | "file" => file1,
60 | "files" => [file1, file2],
61 | }]
62 | }
63 |
64 | request = GraphQL::Stitching::Request.new(
65 | @supergraph,
66 | document,
67 | variables: variables
68 | )
69 |
70 | exe = GraphQL::Stitching::HttpExecutable.new(
71 | url: "",
72 | upload_types: ["Upload"],
73 | )
74 |
75 | result = exe.extract_multipart_form(request, document, variables).tap do |r|
76 | r["operations"] = JSON.parse(r["operations"])
77 | r["map"] = JSON.parse(r["map"])
78 | end
79 |
80 | expected = {
81 | "operations" => {
82 | "query" => document,
83 | "variables" => {
84 | "input" => {
85 | "file" => nil,
86 | "files" => [nil, nil],
87 | },
88 | "inputs" => [{
89 | "file" => nil,
90 | "files" => [nil, nil],
91 | }, {
92 | "file" => nil,
93 | "files" => [nil, nil],
94 | }]
95 | }
96 | },
97 | "map" => {
98 | "0" => [
99 | "variables.input.file",
100 | "variables.input.files.0",
101 | "variables.inputs.0.file",
102 | "variables.inputs.0.files.0",
103 | "variables.inputs.1.file",
104 | "variables.inputs.1.files.0",
105 | ],
106 | "1" => [
107 | "variables.input.files.1",
108 | "variables.inputs.0.files.1",
109 | "variables.inputs.1.files.1",
110 | ]
111 | },
112 | "0" => "A",
113 | "1" => "B",
114 | }
115 |
116 | assert_equal expected, result
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/errors_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/errors"
5 |
6 | describe 'GraphQL::Stitching, errors' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "a" => Schemas::Errors::ElementsA,
10 | "b" => Schemas::Errors::ElementsB,
11 | })
12 | end
13 |
14 | def test_repaths_root_errors
15 | result = plan_and_execute(@supergraph, %|
16 | query {
17 | elementsA(ids: ["10", "18", "36"]) {
18 | name
19 | code
20 | year
21 | }
22 | }
23 | |)
24 |
25 | expected_data = {
26 | "elementsA" => [
27 | {
28 | "name" => "neon",
29 | "code" => "Ne",
30 | "year" => 1898,
31 | },
32 | nil,
33 | {
34 | "name" => "krypton",
35 | "code" => nil,
36 | "year" => nil,
37 | },
38 | ],
39 | }
40 |
41 | expected_errors = [
42 | { "message" => "Not found", "path" => ["elementsA", 1] },
43 | { "message" => "Not found", "path" => ["elementsA", 2] },
44 | ]
45 |
46 | assert_equal expected_data, result["data"]
47 | assert_equal expected_errors, result["errors"]
48 | end
49 |
50 | def test_repaths_nested_errors_onto_list_source
51 | result = plan_and_execute(@supergraph, %|
52 | query {
53 | elementsA(ids: ["10", "36"]) {
54 | name
55 | isotopes {
56 | name
57 | halflife
58 | }
59 | isotope {
60 | name
61 | halflife
62 | }
63 | }
64 | }
65 | |)
66 |
67 | expected_data = {
68 | "elementsA" => [
69 | {
70 | "name" => "neon",
71 | "isotope" => nil,
72 | "isotopes" => [nil],
73 | },
74 | {
75 | "name" => "krypton",
76 | "isotope" => { "name" => "Kr79", "halflife" => "35d" },
77 | "isotopes" => [{ "name" => "Kr79", "halflife" => "35d" }],
78 | },
79 | ],
80 | }
81 |
82 | expected_errors = [
83 | { "message" => "Not found", "path" => ["elementsA", 0, "isotopes", 0] },
84 | { "message" => "Not found", "path" => ["elementsA", 0, "isotope"] },
85 | ]
86 |
87 | assert_equal expected_data, result["data"]
88 | assert_equal expected_errors, result["errors"]
89 | end
90 |
91 | def test_repaths_nested_errors_onto_object_source
92 | result = plan_and_execute(@supergraph, %|
93 | query {
94 | elementA(id: "10") {
95 | name
96 | isotopes {
97 | name
98 | halflife
99 | }
100 | isotope {
101 | name
102 | halflife
103 | }
104 | }
105 | }
106 | |)
107 |
108 | expected_data = {
109 | "elementA" => {
110 | "name" => "neon",
111 | "isotope" => nil,
112 | "isotopes" => [nil],
113 | },
114 | }
115 |
116 | expected_errors = [
117 | { "message" => "Not found", "path" => ["elementA", "isotopes", 0] },
118 | { "message" => "Not found", "path" => ["elementA", "isotope"] },
119 | ]
120 |
121 | assert_equal expected_data, result["data"]
122 | assert_equal expected_errors, result["errors"]
123 | end
124 | end
125 |
--------------------------------------------------------------------------------
/test/graphql/stitching/supergraph/from_definition_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::Supergraph#from_definition" do
6 | def setup
7 | alpha = %|
8 | interface I { id:ID! }
9 | type T implements I { id:ID! a:String }
10 | type Query { a(id:ID!):I @stitch(key: "id") }
11 | |
12 | bravo = %|
13 | type T { id:ID! b:String }
14 | type Query { b(id:ID!):T @stitch(key: "id") }
15 | |
16 |
17 | @supergraph = compose_definitions({ "alpha" => alpha, "bravo" => bravo })
18 | @schema_sdl = @supergraph.to_definition
19 | end
20 |
21 | # is this a composer test now...?
22 | def test_to_definition_annotates_schema
23 | @schema_sdl = squish_string(@schema_sdl)
24 | assert @schema_sdl.include?("directive @key")
25 | assert @schema_sdl.include?("directive @resolver")
26 | assert @schema_sdl.include?("directive @source")
27 | assert @schema_sdl.include?(squish_string(%|
28 | interface I
29 | @key(key: "id", location: "alpha")
30 | @resolver(location: "alpha", key: "id", field: "a", arguments: "id: $.id", argumentTypes: "id: ID!") {
31 | |))
32 | assert @schema_sdl.include?(squish_string(%|
33 | type T implements I
34 | @key(key: "id", location: "alpha")
35 | @key(key: "id", location: "bravo")
36 | @resolver(location: "bravo", key: "id", field: "b", arguments: "id: $.id", argumentTypes: "id: ID!")
37 | @resolver(location: "alpha", key: "id", field: "a", arguments: "id: $.id", argumentTypes: "id: ID!", typeName: "I") {
38 | |))
39 | assert @schema_sdl.include?(%|id: ID! @source(location: "alpha") @source(location: "bravo")|)
40 | assert @schema_sdl.include?(%|a: String @source(location: "alpha")|)
41 | assert @schema_sdl.include?(%|b: String @source(location: "bravo")|)
42 | assert @schema_sdl.include?(%|a(id: ID!): I @source(location: "alpha")|)
43 | assert @schema_sdl.include?(%|b(id: ID!): T @source(location: "bravo")|)
44 | end
45 |
46 | def test_from_definition_restores_supergraph
47 | supergraph_import = GraphQL::Stitching::Supergraph.from_definition(@schema_sdl, executables: {
48 | "alpha" => Proc.new { true },
49 | "bravo" => Proc.new { true },
50 | })
51 |
52 | assert_equal @supergraph.fields, supergraph_import.fields
53 | assert_equal ["alpha", "bravo"], supergraph_import.locations.sort
54 | assert_equal @supergraph.schema.types.keys.sort, supergraph_import.schema.types.keys.sort
55 | assert_equal @supergraph.resolvers, supergraph_import.resolvers
56 | end
57 |
58 | def test_normalizes_executable_location_names
59 | supergraph_import = GraphQL::Stitching::Supergraph.from_definition(@schema_sdl, executables: {
60 | alpha: Proc.new { true },
61 | bravo: Proc.new { true },
62 | })
63 |
64 | assert_equal ["alpha", "bravo"], supergraph_import.locations.sort
65 | end
66 |
67 | def test_errors_for_invalid_executables
68 | assert_error "Invalid executable provided for location" do
69 | GraphQL::Stitching::Supergraph.from_definition(@schema_sdl, executables: {
70 | alpha: Proc.new { true },
71 | bravo: "nope",
72 | })
73 | end
74 | end
75 |
76 | def test_errors_for_missing_executables
77 | assert_error "Invalid executable provided for location" do
78 | GraphQL::Stitching::Supergraph.from_definition(@schema_sdl, executables: {
79 | alpha: Proc.new { true },
80 | })
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/test/schemas/errors.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module Errors
5 | ISOTOPES_A = [
6 | { id: '1', name: 'Ne20' },
7 | { id: '2', name: 'Kr79' },
8 | ].freeze
9 |
10 | ISOTOPES_B = [
11 | { id: '2', halflife: '35d' },
12 | ].freeze
13 |
14 | ELEMENTS_A = [
15 | { id: '10', name: 'neon', isotopes: [ISOTOPES_A[0]], isotope: ISOTOPES_A[0] },
16 | { id: '36', name: 'krypton', isotopes: [ISOTOPES_A[1]], isotope: ISOTOPES_A[1] },
17 | ].freeze
18 |
19 | ELEMENTS_B = [
20 | { id: '10', code: 'Ne', year: 1898 },
21 | { id: '18', code: 'Ar', year: 1894 },
22 | ].freeze
23 |
24 | class ElementsA < GraphQL::Schema
25 | class Isotope < GraphQL::Schema::Object
26 | field :id, ID, null: false
27 | field :name, String, null: false
28 | end
29 |
30 | class Element < GraphQL::Schema::Object
31 | field :id, ID, null: false
32 | field :name, String, null: false
33 | field :isotopes, [Isotope, null: true], null: false
34 | field :isotope, Isotope, null: true
35 | end
36 |
37 | class Query < GraphQL::Schema::Object
38 | field :elements_a, [Element, null: true], null: false do
39 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
40 | argument :ids, [ID], required: true
41 | end
42 |
43 | def elements_a(ids:)
44 | ids.map do |id|
45 | ELEMENTS_A.find { _1[:id] == id } || GraphQL::ExecutionError.new("Not found")
46 | end
47 | end
48 |
49 | field :element_a, Element, null: true do
50 | argument :id, ID, required: true
51 | end
52 |
53 | def element_a(id:)
54 | ELEMENTS_A.find { _1[:id] == id } || GraphQL::ExecutionError.new("Not found")
55 | end
56 |
57 | field :isotope_a, Isotope, null: true do
58 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
59 | argument :id, ID, required: true
60 | end
61 |
62 | def isotope_a(id:)
63 | ISOTOPES_A.find { _1[:id] == id } || GraphQL::ExecutionError.new("Not found")
64 | end
65 | end
66 |
67 | query Query
68 | end
69 |
70 | class ElementsB < GraphQL::Schema
71 | class Isotope < GraphQL::Schema::Object
72 | field :id, ID, null: false
73 | field :halflife, String, null: false
74 | end
75 |
76 | class Element < GraphQL::Schema::Object
77 | field :id, ID, null: false
78 | field :code, String, null: true
79 | field :year, Int, null: true
80 | end
81 |
82 | class Query < GraphQL::Schema::Object
83 | field :elements_b, [Element, null: true], null: false do
84 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
85 | argument :ids, [ID], required: true
86 | end
87 |
88 | def elements_b(ids:)
89 | ids.map do |id|
90 | ELEMENTS_B.find { _1[:id] == id } || GraphQL::ExecutionError.new("Not found")
91 | end
92 | end
93 |
94 | field :isotope_b, Isotope, null: true do
95 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
96 | argument :id, ID, required: true
97 | end
98 |
99 | def isotope_b(id:)
100 | ISOTOPES_B.find { _1[:id] == id } || GraphQL::ExecutionError.new("Not found")
101 | end
102 | end
103 |
104 | query Query
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/util.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL
4 | module Stitching
5 | # General utilities to aid with stitching.
6 | class Util
7 | class TypeStructure
8 | attr_reader :name
9 |
10 | def initialize(list:, null:, name:)
11 | @list = list
12 | @null = null
13 | @name = name
14 | end
15 |
16 | def list?
17 | @list
18 | end
19 |
20 | def null?
21 | @null
22 | end
23 |
24 | def non_null?
25 | !@null
26 | end
27 |
28 | def ==(other)
29 | @list == other.list? && @null == other.null? && @name == other.name
30 | end
31 | end
32 |
33 | class << self
34 | # specifies if a type is a primitive leaf value
35 | def is_leaf_type?(type)
36 | type.kind.scalar? || type.kind.enum?
37 | end
38 |
39 | # strips non-null wrappers from a type
40 | def unwrap_non_null(type)
41 | type = type.of_type while type.non_null?
42 | type
43 | end
44 |
45 | # builds a single-dimensional representation of a wrapped type structure
46 | def flatten_type_structure(type)
47 | structure = []
48 |
49 | while type.list?
50 | structure << TypeStructure.new(
51 | list: true,
52 | null: !type.non_null?,
53 | name: nil,
54 | )
55 |
56 | type = unwrap_non_null(type).of_type
57 | end
58 |
59 | structure << TypeStructure.new(
60 | list: false,
61 | null: !type.non_null?,
62 | name: type.unwrap.graphql_name,
63 | )
64 |
65 | structure
66 | end
67 |
68 | # builds a single-dimensional representation of a wrapped type structure from AST
69 | def flatten_ast_type_structure(ast, structure: [])
70 | null = true
71 |
72 | while ast.is_a?(GraphQL::Language::Nodes::NonNullType)
73 | ast = ast.of_type
74 | null = false
75 | end
76 |
77 | if ast.is_a?(GraphQL::Language::Nodes::ListType)
78 | structure << TypeStructure.new(
79 | list: true,
80 | null: null,
81 | name: nil,
82 | )
83 |
84 | flatten_ast_type_structure(ast.of_type, structure: structure)
85 | else
86 | structure << TypeStructure.new(
87 | list: false,
88 | null: null,
89 | name: ast.name,
90 | )
91 | end
92 |
93 | structure
94 | end
95 |
96 | # expands interfaces and unions to an array of their memberships
97 | # like `schema.possible_types`, but includes child interfaces
98 | def expand_abstract_type(schema, parent_type)
99 | return [] unless parent_type.kind.abstract?
100 | return parent_type.possible_types if parent_type.kind.union?
101 |
102 | result = []
103 | schema.types.each_value do |type|
104 | next unless type <= GraphQL::Schema::Interface && type != parent_type
105 | next unless type.interfaces.include?(parent_type)
106 | result << type
107 | result.push(*expand_abstract_type(schema, type)) if type.kind.interface?
108 | end
109 | result.tap(&:uniq!)
110 | end
111 | end
112 | end
113 | end
114 | end
115 |
--------------------------------------------------------------------------------
/test/schemas/conditionals.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Schemas
4 | module Conditionals
5 | FRUITS = [
6 | { id: '1', extension_id: '11', __typename: 'Apple' },
7 | { id: '2', extension_id: '22', __typename: 'Banana' },
8 | ].freeze
9 |
10 | class ExtensionsA < GraphQL::Schema
11 | class AppleExtension < GraphQL::Schema::Object
12 | field :id, ID, null: false
13 | field :color, String, null: false
14 | end
15 |
16 | class Query < GraphQL::Schema::Object
17 | field :apple_extension, AppleExtension, null: true do
18 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
19 | argument :id, ID, required: true
20 | end
21 |
22 | def apple_extension(id:)
23 | { id: id, color: "red" }
24 | end
25 | end
26 |
27 | query Query
28 | end
29 |
30 | class ExtensionsB < GraphQL::Schema
31 | class BananaExtension < GraphQL::Schema::Object
32 | field :id, ID, null: false
33 | field :shape, String, null: false
34 | end
35 |
36 | class Query < GraphQL::Schema::Object
37 | field :banana_extension, BananaExtension, null: true do
38 | directive GraphQL::Stitching::Directives::Stitch, key: "id"
39 | argument :id, ID, required: true
40 | end
41 |
42 | def banana_extension(id:)
43 | { id: id, shape: "crescent" }
44 | end
45 | end
46 |
47 | query Query
48 | end
49 |
50 | class Abstracts < GraphQL::Schema
51 | module Extension
52 | include GraphQL::Schema::Interface
53 | field :id, ID, null: false
54 | end
55 |
56 | module HasExtension
57 | include GraphQL::Schema::Interface
58 | field :abstract_extension, Extension, null: false
59 |
60 | def abstract_extension
61 | { id: object[:extension_id], __typename: "#{object[:__typename]}Extension" }
62 | end
63 | end
64 |
65 | class AppleExtension < GraphQL::Schema::Object
66 | implements Extension
67 | end
68 |
69 | class Apple < GraphQL::Schema::Object
70 | implements HasExtension
71 | field :id, ID, null: false
72 | field :extensions, AppleExtension, null: false
73 |
74 | def extensions
75 | { id: object[:extension_id], __typename: "AppleExtension" }
76 | end
77 | end
78 |
79 | class BananaExtension < GraphQL::Schema::Object
80 | implements Extension
81 | end
82 |
83 | class Banana < GraphQL::Schema::Object
84 | implements HasExtension
85 | field :id, ID, null: false
86 | field :extensions, BananaExtension, null: false
87 |
88 | def extensions
89 | { id: object[:extension_id], __typename: "BananaExtension" }
90 | end
91 | end
92 |
93 | class Fruit < GraphQL::Schema::Union
94 | possible_types Apple, Banana
95 | end
96 |
97 | class Query < GraphQL::Schema::Object
98 | field :fruits, [Fruit, null: true], null: false do
99 | argument :ids, [ID], required: true
100 | end
101 |
102 | def fruits(ids:)
103 | ids.map { |id| FRUITS.find { _1[:id] == id } }
104 | end
105 | end
106 |
107 | TYPES = {
108 | "Apple" => Apple,
109 | "Banana" => Banana,
110 | "AppleExtension" => AppleExtension,
111 | "BananaExtension" => BananaExtension,
112 | }.freeze
113 |
114 | def self.resolve_type(_type, obj, _ctx)
115 | TYPES.fetch(obj[:__typename])
116 | end
117 |
118 | query Query
119 | end
120 | end
121 | end
122 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/conditionals_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/conditionals"
5 |
6 | describe 'GraphQL::Stitching, type conditions' do
7 | def setup
8 | @supergraph = compose_definitions({
9 | "exa" => Schemas::Conditionals::ExtensionsA,
10 | "exb" => Schemas::Conditionals::ExtensionsB,
11 | "base" => Schemas::Conditionals::Abstracts,
12 | })
13 |
14 | @query = %|
15 | query($ids: [ID!]!) {
16 | fruits(ids: $ids) {
17 | ...on Apple { extensions { color } }
18 | ...on Banana { extensions { shape } }
19 | __typename
20 | }
21 | }
22 | |
23 | end
24 |
25 | def test_performs_specific_queries_planned_for_the_returned_type
26 | result = plan_and_execute(@supergraph, @query, {
27 | "ids" => ["1"]
28 | }) do |_planner, executor|
29 | assert_equal 2, executor.query_count
30 | end
31 |
32 | expected = {
33 | "fruits" => [{
34 | "extensions" => {
35 | "color" => "red",
36 | },
37 | "__typename" => "Apple",
38 | }],
39 | }
40 |
41 | assert_equal expected, result["data"]
42 | end
43 |
44 | def test_performs_all_queries_for_all_returned_types
45 | result = plan_and_execute(@supergraph, @query, {
46 | "ids" => ["1", "2"]
47 | }) do |_planner, executor|
48 | assert_equal 3, executor.query_count
49 | end
50 |
51 | expected = {
52 | "fruits" => [{
53 | "extensions" => {
54 | "color" => "red",
55 | },
56 | "__typename" => "Apple",
57 | }, {
58 | "extensions" => {
59 | "shape" => "crescent",
60 | },
61 | "__typename" => "Banana",
62 | }],
63 | }
64 |
65 | assert_equal expected, result["data"]
66 | end
67 |
68 | def test_performs_specific_queries_planned_for_the_returned_type_via_fragment
69 | @query = %|
70 | query($ids: [ID!]!) {
71 | fruits(ids: $ids) {
72 | ...on HasExtension {
73 | abstractExtension {
74 | ...on AppleExtension { color }
75 | ...on BananaExtension { shape }
76 | }
77 | }
78 | }
79 | }
80 | |
81 |
82 | result = plan_and_execute(@supergraph, @query, {
83 | "ids" => ["1"]
84 | }) do |_planner, executor|
85 | assert_equal 2, executor.query_count
86 | end
87 |
88 | expected = {
89 | "fruits" => [{
90 | "abstractExtension" => {
91 | "color" => "red",
92 | }
93 | }]
94 | }
95 |
96 | assert_equal expected, result["data"]
97 | end
98 |
99 | def test_performs_all_queries_for_all_returned_types_via_fragment
100 | @query = %|
101 | query($ids: [ID!]!) {
102 | fruits(ids: $ids) {
103 | ...on HasExtension {
104 | abstractExtension {
105 | ...ExtFields
106 | }
107 | }
108 | }
109 | }
110 | fragment ExtFields on Extension {
111 | ...on AppleExtension { color }
112 | ...on BananaExtension { shape }
113 | }
114 | |
115 |
116 | result = plan_and_execute(@supergraph, @query, {
117 | "ids" => ["1", "2"]
118 | }) do |_planner, executor|
119 | assert_equal 3, executor.query_count
120 | end
121 |
122 | expected = {
123 | "fruits" => [{
124 | "abstractExtension" => {
125 | "color" => "red",
126 | }
127 | }, {
128 | "abstractExtension" => {
129 | "shape" => "crescent",
130 | }
131 | }]
132 | }
133 |
134 | assert_equal expected, result["data"]
135 | end
136 | end
137 |
--------------------------------------------------------------------------------
/lib/graphql/stitching/supergraph/from_definition.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GraphQL::Stitching
4 | class Supergraph
5 | class << self
6 | def validate_executable!(location, executable)
7 | return true if executable.is_a?(Class) && executable <= GraphQL::Schema
8 | return true if executable && executable.respond_to?(:call)
9 | raise StitchingError, "Invalid executable provided for location `#{location}`."
10 | end
11 |
12 | def from_definition(schema, executables:)
13 | if schema.is_a?(String)
14 | schema = if GraphQL::Stitching.supports_visibility?
15 | GraphQL::Schema.from_definition(schema, base_types: BASE_TYPES)
16 | else
17 | GraphQL::Schema.from_definition(schema)
18 | end
19 | end
20 |
21 | field_map = {}
22 | resolver_map = {}
23 | possible_locations = {}
24 | visibility_definition = schema.directives[GraphQL::Stitching.visibility_directive]
25 | visibility_profiles = visibility_definition&.get_argument("profiles")&.default_value || EMPTY_ARRAY
26 |
27 | schema.types.each do |type_name, type|
28 | next if type.introspection?
29 |
30 | # Collect/build key definitions for each type
31 | locations_by_key = type.directives.each_with_object({}) do |directive, memo|
32 | next unless directive.graphql_name == Directives::SupergraphKey.graphql_name
33 |
34 | kwargs = directive.arguments.keyword_arguments
35 | memo[kwargs[:key]] ||= []
36 | memo[kwargs[:key]] << kwargs[:location]
37 | end
38 |
39 | key_definitions = locations_by_key.each_with_object({}) do |(key, locations), memo|
40 | memo[key] = TypeResolver.parse_key(key, locations)
41 | end
42 |
43 | # Collect/build resolver definitions for each type
44 | type.directives.each do |d|
45 | next unless d.graphql_name == Directives::SupergraphResolver.graphql_name
46 |
47 | kwargs = d.arguments.keyword_arguments
48 | resolver_map[type_name] ||= []
49 | resolver_map[type_name] << TypeResolver.new(
50 | location: kwargs[:location],
51 | type_name: kwargs.fetch(:type_name, type_name),
52 | field: kwargs[:field],
53 | list: kwargs[:list] || false,
54 | key: key_definitions[kwargs[:key]],
55 | arguments: TypeResolver.parse_arguments_with_type_defs(kwargs[:arguments], kwargs[:argument_types]),
56 | )
57 | end
58 |
59 | next unless type.kind.fields?
60 |
61 | type.fields.each do |field_name, field|
62 | # Collection locations for each field definition
63 | field.directives.each do |d|
64 | next unless d.graphql_name == Directives::SupergraphSource.graphql_name
65 |
66 | location = d.arguments.keyword_arguments[:location]
67 | field_map[type_name] ||= {}
68 | field_map[type_name][field_name] ||= []
69 | field_map[type_name][field_name] << location
70 | possible_locations[location] = true
71 | end
72 | end
73 | end
74 |
75 | executables = possible_locations.each_key.each_with_object({}) do |location, memo|
76 | executable = executables[location] || executables[location.to_sym]
77 | if validate_executable!(location, executable)
78 | memo[location] = executable
79 | end
80 | end
81 |
82 | new(
83 | schema: schema,
84 | fields: field_map,
85 | resolvers: resolver_map,
86 | visibility_profiles: visibility_profiles,
87 | executables: executables,
88 | )
89 | end
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_enum_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging enums' do
6 |
7 | def test_merges_enum_and_value_descriptions
8 | a = %|"""a""" enum Status { """a""" YES } type Query { status:Status }|
9 | b = %|"""b""" enum Status { """b""" YES } type Query { status:Status }|
10 |
11 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
12 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
13 | })
14 |
15 | assert_equal "a/b", supergraph.schema.types["Status"].description
16 | assert_equal "a/b", supergraph.schema.types["Status"].values["YES"].description
17 | end
18 |
19 | def test_merges_enum_and_value_directives
20 | a = %|
21 | directive @fizzbuzz(arg: String!) on ENUM \| ENUM_VALUE
22 | enum Status @fizzbuzz(arg: "a") { YES @fizzbuzz(arg: "a") }
23 | type Query { status:Status }
24 | |
25 |
26 | b = %|
27 | directive @fizzbuzz(arg: String!) on ENUM \| ENUM_VALUE
28 | enum Status @fizzbuzz(arg: "b") { YES @fizzbuzz(arg: "b") }
29 | type Query { status:Status }
30 | |
31 |
32 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
33 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
34 | })
35 |
36 | assert_equal "a/b", supergraph.schema.types["Status"].directives.first.arguments.keyword_arguments[:arg]
37 | assert_equal "a/b", supergraph.schema.types["Status"].values["YES"].directives.first.arguments.keyword_arguments[:arg]
38 | end
39 |
40 | def test_merges_enum_values_using_union_when_readonly
41 | a = %|enum Status { YES NO } type Query { status:Status }|
42 | b = %|enum Status { YES NO MAYBE } type Query { status:Status }|
43 |
44 | supergraph = compose_definitions({ "a" => a, "b" => b })
45 |
46 | assert_equal ["MAYBE", "NO", "YES"], supergraph.schema.types["Status"].values.keys.sort
47 | end
48 |
49 | def test_merges_enum_values_using_intersection_when_input_via_field_arg
50 | a = %|enum Status { YES NO } type Query { status1:Status }|
51 | b = %|enum Status { YES NO MAYBE } type Query { status2(s:Status):Status }|
52 |
53 | supergraph = compose_definitions({ "a" => a, "b" => b })
54 |
55 | assert_equal ["NO", "YES"], supergraph.schema.types["Status"].values.keys.sort
56 | end
57 |
58 | def test_merges_enum_values_using_intersection_when_input_via_object
59 | a = %|enum Status { YES NO } input MyStatus { status:Status } type Query { status1(s:MyStatus):Status }|
60 | b = %|enum Status { YES NO MAYBE } type Query { status:Status }|
61 |
62 | supergraph = compose_definitions({ "a" => a, "b" => b })
63 |
64 | assert_equal ["NO", "YES"], supergraph.schema.types["Status"].values.keys.sort
65 | end
66 |
67 | class SchemaAlpha < GraphQL::Schema
68 | class Toggle < GraphQL::Schema::Enum
69 | value("ON", value: "1")
70 | value("OFF", value: "0")
71 | end
72 |
73 | class Query < GraphQL::Schema::Object
74 | field :a, Toggle
75 | end
76 | query Query
77 | end
78 |
79 | class SchemaBravo < GraphQL::Schema
80 | class Toggle < GraphQL::Schema::Enum
81 | value("ON", value: true)
82 | value("OFF", value: false)
83 | end
84 |
85 | class Query < GraphQL::Schema::Object
86 | field :b, Toggle
87 | end
88 | query Query
89 | end
90 |
91 | def test_merges_class_based_enums_with_value_mappings
92 | supergraph = compose_definitions({ "a" => SchemaAlpha, "b" => SchemaBravo })
93 |
94 | assert_equal ["OFF", "ON"], supergraph.schema.types["Toggle"].values.keys.sort
95 | assert_equal ["OFF", "ON"], supergraph.schema.types["Toggle"].values.values.map(&:graphql_name).sort
96 | assert_equal ["OFF", "ON"], supergraph.schema.types["Toggle"].values.values.map(&:value).sort
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_directive_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging directives' do
6 | def test_merges_directive_definitions
7 | a = %|
8 | """a"""
9 | directive @fizzbuzz(a: String!) on OBJECT
10 | type Test @fizzbuzz(a: "A") { field: String }
11 | type Query { test: Test }
12 | |
13 |
14 | b = %|
15 | """b"""
16 | directive @fizzbuzz(a: String!, b: String) on OBJECT
17 | type Test @fizzbuzz(a: "A", b: "B") { field: String }
18 | type Query { test: Test }
19 | |
20 |
21 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
22 | description_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
23 | })
24 |
25 | directive_definition = supergraph.schema.directives["fizzbuzz"]
26 | assert_equal "a/b", directive_definition.description
27 | assert_equal ["a"], directive_definition.arguments.keys
28 | end
29 |
30 | def test_combines_distinct_directives_assigned_to_an_element
31 | a = %|
32 | directive @fizz(arg: String!) on OBJECT
33 | directive @buzz on OBJECT
34 | type Test @fizz(arg: "a") @buzz { field: String }
35 | type Query { test:Test }
36 | |
37 |
38 | b = %|
39 | directive @fizz(arg: String!) on OBJECT
40 | directive @widget on OBJECT
41 | type Test @fizz(arg: "b") @widget { field: String }
42 | type Query { test:Test }
43 | |
44 |
45 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
46 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
47 | })
48 |
49 | directives = supergraph.schema.types["Test"].directives
50 |
51 | assert_equal 3, directives.length
52 | assert_equal ["buzz", "fizz", "widget"], directives.map(&:graphql_name).sort
53 | assert_equal "a/b", directives.find { _1.graphql_name == "fizz" }.arguments.keyword_arguments[:arg]
54 | end
55 |
56 | def test_omits_stitching_directives_and_includes_supergraph_directives
57 | a = %|
58 | directive @stitch(key: String!) repeatable on FIELD_DEFINITION
59 | type Test { id: ID! a: String }
60 | type Query { testA(id: ID!): Test @stitch(key: "id") }
61 | |
62 |
63 | b = %|
64 | directive @stitch(key: String!) repeatable on FIELD_DEFINITION
65 | type Test { id: ID! b: String }
66 | type Query { testB(id: ID!): Test @stitch(key: "id") }
67 | |
68 |
69 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
70 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
71 | })
72 |
73 | assert !supergraph.schema.directives.key?("stitch")
74 | assert supergraph.schema.directives.key?("key")
75 | assert supergraph.schema.directives.key?("resolver")
76 | assert supergraph.schema.directives.key?("source")
77 | assert_equal ["source"], supergraph.schema.query.get_field("testA").directives.map(&:graphql_name)
78 | assert_equal ["source"], supergraph.schema.query.get_field("testB").directives.map(&:graphql_name)
79 | end
80 |
81 | def test_merges_camel_case_directive_values
82 | a = %|
83 | directive @fizzbuzz(sfooBar: String!) on OBJECT
84 | type Test @fizzbuzz(sfooBar: "A") { field: String }
85 | type Query { test: Test }
86 | |
87 |
88 | b = %|
89 | directive @fizzbuzz(sfooBar: String!) on OBJECT
90 | type Test @fizzbuzz(sfooBar: "B") { field: String }
91 | type Query { test: Test }
92 | |
93 |
94 | supergraph = compose_definitions({ "a" => a, "b" => b }, {
95 | directive_kwarg_merger: ->(str_by_location, _info) { str_by_location.values.join("/") }
96 | })
97 |
98 | directives = supergraph.schema.get_type("Test").directives
99 | assert_equal "A/B", directives.first.arguments.keyword_arguments[:sfoo_bar]
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/visibility_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/visibility"
5 |
6 | describe 'GraphQL::Stitching, visibility' do
7 | def setup
8 | skip unless GraphQL::Stitching.supports_visibility?
9 |
10 | @supergraph = compose_definitions({
11 | "price" => Schemas::Visibility::PriceSchema,
12 | "inventory" => Schemas::Visibility::InventorySchema,
13 | }, {
14 | visibility_profiles: ["public", "private"],
15 | })
16 |
17 | @full_query = %|{
18 | sprocket(id: "1") {
19 | id
20 | price
21 | msrp
22 | quantityAvailable
23 | quantityInStock
24 | }
25 | sprockets(ids: ["1"]) {
26 | id
27 | price
28 | msrp
29 | quantityAvailable
30 | quantityInStock
31 | }
32 | }|
33 |
34 | @full_record = {
35 | "id" => "1",
36 | "price" => 20.99,
37 | "msrp" => 10.99,
38 | "quantityAvailable" => 23,
39 | "quantityInStock" => 35,
40 | }
41 | end
42 |
43 | def test_fully_accessible_with_no_visibility_profile
44 | request = GraphQL::Stitching::Request.new(@supergraph, @full_query, context: {})
45 | assert request.validate.empty?
46 |
47 | expected = {
48 | "sprocket" => @full_record,
49 | "sprockets" => [@full_record],
50 | }
51 |
52 | assert_equal expected, request.execute.dig("data")
53 | end
54 |
55 | def test_no_private_or_hidden_fields_for_public_profile
56 | request = GraphQL::Stitching::Request.new(@supergraph, @full_query, context: {
57 | visibility_profile: "public",
58 | })
59 |
60 | expected = [
61 | { "code" => "undefinedField", "typeName" => "Sprocket", "fieldName" => "id" },
62 | { "code" => "undefinedField", "typeName" => "Sprocket", "fieldName" => "msrp" },
63 | { "code" => "undefinedField", "typeName" => "Sprocket", "fieldName" => "quantityInStock" },
64 | { "code" => "undefinedField", "typeName" => "Query", "fieldName" => "sprockets" },
65 | ]
66 |
67 | assert_equal expected, request.validate.map(&:to_h).map { _1["extensions"] }
68 | end
69 |
70 | def test_no_hidden_fields_for_private_profile
71 | request = GraphQL::Stitching::Request.new(@supergraph, @full_query, context: {
72 | visibility_profile: "private",
73 | })
74 |
75 | expected = [
76 | { "code" => "undefinedField", "typeName" => "Sprocket", "fieldName" => "id" },
77 | { "code" => "undefinedField", "typeName" => "Sprocket", "fieldName" => "id" },
78 | ]
79 |
80 | assert_equal expected, request.validate.map(&:to_h).map { _1["extensions"] }
81 | end
82 |
83 | def test_accesses_stitched_data_in_public_profile
84 | query = %|{
85 | sprocket(id: "1") {
86 | price
87 | quantityAvailable
88 | }
89 | }|
90 |
91 | request = GraphQL::Stitching::Request.new(@supergraph, query, context: {
92 | visibility_profile: "public",
93 | })
94 |
95 | expected = {
96 | "sprocket" => {
97 | "price" => 20.99,
98 | "quantityAvailable" => 23,
99 | },
100 | }
101 |
102 | assert_equal expected, request.execute.dig("data")
103 | end
104 |
105 | def test_accesses_stitched_data_in_private_profile
106 | query = %|{
107 | sprockets(ids: ["1"]) {
108 | price
109 | msrp
110 | quantityAvailable
111 | quantityInStock
112 | }
113 | }|
114 |
115 | request = GraphQL::Stitching::Request.new(@supergraph, query, context: {
116 | visibility_profile: "private",
117 | })
118 |
119 | expected = {
120 | "sprockets" => [{
121 | "price" => 20.99,
122 | "msrp" => 10.99,
123 | "quantityAvailable" => 23,
124 | "quantityInStock" => 35,
125 | }],
126 | }
127 |
128 | assert_equal expected, request.execute.dig("data")
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/test/graphql/stitching/executor/executor_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe "GraphQL::Stitching::Executor" do
6 | def mock_execs(source, returns, operation_name: nil, variables: nil)
7 | alpha = %|
8 | #{STITCH_DEFINITION}
9 | directive @inContext(lang: String!) on QUERY \| MUTATION
10 | type Product { id: ID! name: String }
11 | type Query { product(id: ID!): Product @stitch(key: "id") }
12 | |
13 | bravo = %|
14 | type Product { id: ID! }
15 | type Query { featured: [Product!]! }
16 | |
17 |
18 | results = []
19 | supergraph = GraphQL::Stitching::Composer.new.perform({
20 | alpha: {
21 | schema: GraphQL::Schema.from_definition(alpha),
22 | executable: -> (req, src, vars) {
23 | results << { location: "alpha", source: src, variables: vars }
24 | { "data" => returns.shift }
25 | },
26 | },
27 | bravo: {
28 | schema: GraphQL::Schema.from_definition(bravo),
29 | executable: -> (req, src, vars) {
30 | results << { location: "bravo", source: src, variables: vars }
31 | { "data" => returns.shift }
32 | },
33 | },
34 | })
35 |
36 | GraphQL::Stitching::Request.new(
37 | supergraph,
38 | source,
39 | operation_name: operation_name,
40 | variables: variables,
41 | ).execute
42 |
43 | results
44 | end
45 |
46 | def test_with_batching
47 | req = %|{ featured { name } }|
48 |
49 | expected_source1 = %|
50 | query{ featured { _export_id: id _export___typename: __typename } }
51 | |
52 | expected_source2 = %|
53 | query($_0_0_key_0:ID!,$_0_1_key_0:ID!,$_0_2_key_0:ID!){
54 | _0_0_result: product(id:$_0_0_key_0) { name }
55 | _0_1_result: product(id:$_0_1_key_0) { name }
56 | _0_2_result: product(id:$_0_2_key_0) { name }
57 | }
58 | |
59 |
60 | expected_vars1 = {}
61 | expected_vars2 = {
62 | "_0_0_key_0" => "1",
63 | "_0_1_key_0" => "2",
64 | "_0_2_key_0" => "3",
65 | }
66 |
67 | execs = mock_execs(req, [
68 | {
69 | "featured" => [
70 | { "_export_id" => "1", "_export___typename" => "Product" },
71 | { "_export_id" => "2", "_export___typename" => "Product" },
72 | { "_export_id" => "3", "_export___typename" => "Product" },
73 | ]
74 | },
75 | {
76 | "_0_0_result" => { "name" => "Potato" },
77 | "_0_1_result" => { "name" => "Carrot" },
78 | "_0_2_result" => { "name" => "Turnip" },
79 | },
80 | ])
81 |
82 | assert_equal 2, execs.length
83 |
84 | assert_equal "bravo", execs[0][:location]
85 | assert_equal squish_string(expected_source1), execs[0][:source]
86 | assert_equal expected_vars1, execs[0][:variables]
87 |
88 | assert_equal "alpha", execs[1][:location]
89 | assert_equal squish_string(expected_source2), execs[1][:source]
90 | assert_equal expected_vars2, execs[1][:variables]
91 | end
92 |
93 | def test_with_operation_name_and_directives
94 | req = %|query Test @inContext(lang: "EN") { featured { name } }|
95 |
96 | expected_source1 = %|
97 | query Test_1 @inContext(lang: "EN") { featured { _export_id: id _export___typename: __typename } }
98 | |
99 | expected_source2 = %|
100 | query Test_2($_0_0_key_0:ID!) @inContext(lang: "EN") { _0_0_result: product(id:$_0_0_key_0) { name } }
101 | |
102 |
103 | expected_vars1 = {}
104 | expected_vars2 = {
105 | "_0_0_key_0" => "1",
106 | }
107 |
108 | execs = mock_execs(req, [
109 | { "featured" => [{ "_export_id" => "1", "_export___typename" => "Product" }] },
110 | { "_0_0_result" => { "name" => "Potato" } },
111 | ], operation_name: "Test")
112 |
113 | assert_equal 2, execs.length
114 |
115 | assert_equal "bravo", execs[0][:location]
116 | assert_equal squish_string(expected_source1), execs[0][:source]
117 | assert_equal expected_vars1, execs[0][:variables]
118 |
119 | assert_equal "alpha", execs[1][:location]
120 | assert_equal squish_string(expected_source2), execs[1][:source]
121 | assert_equal expected_vars2, execs[1][:variables]
122 | end
123 | end
124 |
--------------------------------------------------------------------------------
/test/graphql/stitching/composer/merge_root_objects_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | describe 'GraphQL::Stitching::Composer, merging root objects' do
6 |
7 | def test_merges_fields_of_root_scopes
8 | a = "type Query { a:String } type Mutation { a:String } type Subscription { a:String }"
9 | b = "type Query { b:String } type Mutation { b:String } type Subscription { b:String }"
10 |
11 | info = compose_definitions({ "a" => a, "b" => b })
12 | assert_equal ["a","b"], info.schema.types["Query"].fields.keys.sort
13 | assert_equal ["a","b"], info.schema.types["Mutation"].fields.keys.sort
14 | assert_equal ["a","b"], info.schema.types["Subscription"].fields.keys.sort
15 | end
16 |
17 | def test_merges_fields_of_root_scopes_from_custom_names
18 | a = "type RootQ { a:String } type RootM { a:String } schema { query:RootQ mutation:RootM }"
19 | b = "type Query { b:String } type Mutation { b:String }"
20 |
21 | info = compose_definitions({ "a" => a, "b" => b })
22 | assert_equal ["a","b"], info.schema.types["Query"].fields.keys.sort
23 | assert_equal ["a","b"], info.schema.types["Mutation"].fields.keys.sort
24 | end
25 |
26 | def test_merges_fields_of_root_scopes_into_custom_names
27 | a = "type Query { a:String } type Mutation { a:String }"
28 | b = "type Query { b:String } type Mutation { b:String }"
29 |
30 | info = compose_definitions({ "a" => a, "b" => b }, {
31 | query_name: "RootQuery",
32 | mutation_name: "RootMutation",
33 | })
34 |
35 | assert_equal ["a","b"], info.schema.types["RootQuery"].fields.keys.sort
36 | assert_equal ["a","b"], info.schema.types["RootMutation"].fields.keys.sort
37 | assert_nil info.schema.get_type("Query")
38 | assert_nil info.schema.get_type("Mutation")
39 | end
40 |
41 | def test_errors_for_query_type_name_conflict
42 | a = "type Query { a:String } type Boom { a:String }"
43 |
44 | assert_error('Query name "Boom" is used', CompositionError) do
45 | compose_definitions({ "a" => a }, { query_name: "Boom" })
46 | end
47 | end
48 |
49 | def test_errors_for_mutation_type_name_conflict
50 | a = "type Query { a:String } type Mutation { a:String } type Boom { a:String }"
51 |
52 | assert_error('Mutation name "Boom" is used', CompositionError) do
53 | compose_definitions({ "a" => a }, { mutation_name: "Boom" })
54 | end
55 | end
56 |
57 | def test_prioritizes_last_root_field_location_by_default
58 | a = "type Query { f:String } type Mutation { f:String }"
59 | b = "type Query { f:String } type Mutation { f:String }"
60 |
61 | delegation_map = compose_definitions({ "a" => a, "b" => b }).fields
62 |
63 | assert_equal ["b", "a"], delegation_map["Query"]["f"]
64 | assert_equal ["b", "a"], delegation_map["Mutation"]["f"]
65 | end
66 |
67 | def test_prioritizes_root_field_location_selector_choice
68 | a = "type Query { f:String } type Mutation { f:String }"
69 | b = "type Query { f:String } type Mutation { f:String }"
70 |
71 | delegation_map = compose_definitions({ "a" => a, "b" => b }, {
72 | root_field_location_selector: ->(_locations, _info) { "a" }
73 | }).fields
74 |
75 | assert_equal ["a", "b"], delegation_map["Query"]["f"]
76 | assert_equal ["a", "b"], delegation_map["Mutation"]["f"]
77 | end
78 |
79 | def test_prioritizes_root_entrypoints_locations
80 | a = "type Query { f:String } type Mutation { f:String }"
81 | b = "type Query { f:String } type Mutation { f:String }"
82 |
83 | delegation_map = compose_definitions({ "a" => a, "b" => b }, {
84 | root_entrypoints: { "Query.f" => "a", "Mutation.f" => "a" },
85 | }).fields
86 |
87 | assert_equal ["a", "b"], delegation_map["Query"]["f"]
88 | assert_equal ["a", "b"], delegation_map["Mutation"]["f"]
89 | end
90 |
91 | def test_errors_for_invalid_root_entrypoints
92 | a = "type Query { f:String } type Mutation { f:String }"
93 | b = "type Query { f:String } type Mutation { f:String }"
94 |
95 | assert_error("Invalid `root_entrypoints` configuration", CompositionError) do
96 | compose_definitions({ "a" => a, "b" => b }, {
97 | root_entrypoints: { "Query.f" => "invalid" },
98 | })
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/test/graphql/stitching/integration/composite_keys_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../schemas/composite_keys"
5 |
6 | describe 'GraphQL::Stitching, composite keys' do
7 | def test_queries_through_multiple_composite_keys_from_outer_edge
8 | @supergraph = compose_definitions({
9 | "a" => Schemas::CompositeKeys::PagesById,
10 | "b" => Schemas::CompositeKeys::PagesBySku,
11 | "c" => Schemas::CompositeKeys::PagesByScopedHandle,
12 | "d" => Schemas::CompositeKeys::PagesByOwner,
13 | })
14 |
15 | # id > sku > handle scope > owner { id type }
16 | query = %|{ pagesById(ids: ["1", "2"]) { id a b c d title } }|
17 | result = plan_and_execute(@supergraph, query)
18 | expected = {
19 | "pagesById" => [
20 | { "id" => "1", "a" => "a1", "b" => "b1", "c" => "c1", "d" => "d1", "title" => "Mercury, Planet" },
21 | { "id" => "2", "a" => "a2", "b" => "b2", "c" => "c2", "d" => "d2", "title" => "Mercury, Element" },
22 | ],
23 | }
24 |
25 | assert_equal expected, result["data"]
26 | end
27 |
28 | def test_queries_through_multiple_composite_keys_from_center
29 | @supergraph = compose_definitions({
30 | "a" => Schemas::CompositeKeys::PagesById,
31 | "b" => Schemas::CompositeKeys::PagesBySku,
32 | "c" => Schemas::CompositeKeys::PagesByScopedHandle,
33 | "d" => Schemas::CompositeKeys::PagesByOwner,
34 | })
35 |
36 | # id < sku < handle scope > owner { id type }
37 | query = %|{
38 | pagesByHandle(keys: [
39 | { handle: "mercury", scope: "planet" },
40 | { handle: "mercury", scope: "automobile" },
41 | ]) { id a b c d title }
42 | }|
43 |
44 | result = plan_and_execute(@supergraph, query)
45 | expected = {
46 | "pagesByHandle" => [
47 | { "id" => "1", "a" => "a1", "b" => "b1", "c" => "c1", "d" => "d1", "title" => "Mercury, Planet" },
48 | { "id" => "3", "a" => "a3", "b" => "b3", "c" => "c3", "d" => "d3", "title" => "Mercury, Automobile" },
49 | ],
50 | }
51 |
52 | assert_equal expected, result["data"]
53 | end
54 |
55 | def test_queries_through_single_composite_key
56 | @supergraph = compose_definitions({
57 | "c" => Schemas::CompositeKeys::PagesByScopedHandle,
58 | "e" => {
59 | schema: Schemas::CompositeKeys::PagesByScopedHandleOrOwner,
60 | stitch: [{
61 | field_name: "pagesByHandle2",
62 | key: "handle scope",
63 | arguments: "keys: { handle: $.handle, scope: $.scope }",
64 | }],
65 | }
66 | })
67 |
68 | # "handle scope" > "handle scope"
69 | query = %|{
70 | pagesByHandle(keys: [
71 | { handle: "mercury", scope: "planet" },
72 | { handle: "mercury", scope: "automobile" },
73 | ]) { c e title }
74 | }|
75 |
76 | result = plan_and_execute(@supergraph, query)
77 | expected = {
78 | "pagesByHandle" => [
79 | { "c" => "c1", "e" => "e1", "title" => "Mercury, Planet" },
80 | { "c" => "c3", "e" => "e3", "title" => "Mercury, Automobile" },
81 | ],
82 | }
83 |
84 | assert_equal expected, result["data"]
85 | end
86 |
87 | def test_queries_through_single_composite_key_with_nesting
88 | @supergraph = compose_definitions({
89 | "d" => Schemas::CompositeKeys::PagesByOwner,
90 | "e" => {
91 | schema: Schemas::CompositeKeys::PagesByScopedHandleOrOwner,
92 | stitch: [{
93 | field_name: "pagesByOwner2",
94 | key: "owner { id type }",
95 | arguments: "keys: { id: $.owner.id, type: $.owner.type }",
96 | }],
97 | }
98 | })
99 |
100 | # "owner { id type }" > "owner { id type }"
101 | query = %|{
102 | pagesByOwner(keys: [
103 | { id: "1", type: "Planet" },
104 | { id: "1", type: "Element" },
105 | ]) { d e title }
106 | }|
107 |
108 | result = plan_and_execute(@supergraph, query)
109 | expected = {
110 | "pagesByOwner" => [
111 | { "d" => "d1", "e" => "e1", "title" => "Mercury, Planet" },
112 | { "d" => "d2", "e" => "e2", "title" => "Mercury, Element" },
113 | ],
114 | }
115 |
116 | assert_equal expected, result["data"]
117 | end
118 | end
119 |
--------------------------------------------------------------------------------