├── 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 | ![Library flow](./images/library.png) 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 | --------------------------------------------------------------------------------