graphql-stitching 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +27 -0
  3. data/.gitignore +59 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +11 -0
  6. data/Gemfile.lock +49 -0
  7. data/LICENSE +21 -0
  8. data/Procfile +3 -0
  9. data/README.md +329 -0
  10. data/Rakefile +12 -0
  11. data/docs/README.md +14 -0
  12. data/docs/composer.md +69 -0
  13. data/docs/document.md +15 -0
  14. data/docs/executor.md +29 -0
  15. data/docs/gateway.md +106 -0
  16. data/docs/images/library.png +0 -0
  17. data/docs/images/merging.png +0 -0
  18. data/docs/images/stitching.png +0 -0
  19. data/docs/planner.md +43 -0
  20. data/docs/shaper.md +20 -0
  21. data/docs/supergraph.md +65 -0
  22. data/example/gateway.rb +50 -0
  23. data/example/graphiql.html +153 -0
  24. data/example/remote1.rb +26 -0
  25. data/example/remote2.rb +26 -0
  26. data/graphql-stitching.gemspec +34 -0
  27. data/lib/graphql/stitching/composer/base_validator.rb +11 -0
  28. data/lib/graphql/stitching/composer/validate_boundaries.rb +80 -0
  29. data/lib/graphql/stitching/composer/validate_interfaces.rb +24 -0
  30. data/lib/graphql/stitching/composer.rb +442 -0
  31. data/lib/graphql/stitching/document.rb +59 -0
  32. data/lib/graphql/stitching/executor.rb +254 -0
  33. data/lib/graphql/stitching/gateway.rb +120 -0
  34. data/lib/graphql/stitching/planner.rb +323 -0
  35. data/lib/graphql/stitching/planner_operation.rb +59 -0
  36. data/lib/graphql/stitching/remote_client.rb +25 -0
  37. data/lib/graphql/stitching/shaper.rb +92 -0
  38. data/lib/graphql/stitching/supergraph.rb +171 -0
  39. data/lib/graphql/stitching/util.rb +63 -0
  40. data/lib/graphql/stitching/version.rb +7 -0
  41. data/lib/graphql/stitching.rb +30 -0
  42. metadata +142 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class PlannerOperation
6
+ attr_reader :key, :location, :parent_type, :type_condition, :operation_type, :insertion_path
7
+ attr_accessor :after_key, :selections, :variables, :boundary
8
+
9
+ def initialize(
10
+ key:,
11
+ location:,
12
+ parent_type:,
13
+ operation_type: "query",
14
+ insertion_path: [],
15
+ type_condition: nil,
16
+ after_key: nil,
17
+ selections: [],
18
+ variables: [],
19
+ boundary: nil
20
+ )
21
+ @key = key
22
+ @after_key = after_key
23
+ @location = location
24
+ @parent_type = parent_type
25
+ @operation_type = operation_type
26
+ @insertion_path = insertion_path
27
+ @type_condition = type_condition
28
+ @selections = selections
29
+ @variables = variables
30
+ @boundary = boundary
31
+ end
32
+
33
+ def selection_set
34
+ op = GraphQL::Language::Nodes::OperationDefinition.new(selections: @selections)
35
+ GraphQL::Language::Printer.new.print(op).gsub!(/\s+/, " ").strip!
36
+ end
37
+
38
+ def variable_set
39
+ @variables.each_with_object({}) do |(variable_name, value_type), memo|
40
+ memo[variable_name] = GraphQL::Language::Printer.new.print(value_type)
41
+ end
42
+ end
43
+
44
+ def to_h
45
+ {
46
+ "key" => @key,
47
+ "after_key" => @after_key,
48
+ "location" => @location,
49
+ "operation_type" => @operation_type,
50
+ "insertion_path" => @insertion_path,
51
+ "type_condition" => @type_condition,
52
+ "selections" => selection_set,
53
+ "variables" => variable_set,
54
+ "boundary" => @boundary,
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module GraphQL
8
+ module Stitching
9
+ class RemoteClient
10
+ def initialize(url:, headers:{})
11
+ @url = url
12
+ @headers = headers
13
+ end
14
+
15
+ def call(location, document, variables)
16
+ response = Net::HTTP.post(
17
+ URI(@url),
18
+ { "query" => document, "variables" => variables }.to_json,
19
+ { "Content-Type" => "application/json" }.merge!(@headers)
20
+ )
21
+ JSON.parse(response.body)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,92 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module GraphQL
5
+ module Stitching
6
+ class Shaper
7
+ def initialize(supergraph:, document:, raw:)
8
+ @supergraph = supergraph
9
+ @document = document
10
+ @result = raw
11
+ @errors = []
12
+ end
13
+
14
+ def perform!
15
+ if @result.key?("data") && ! @result["data"].empty?
16
+ munge_entry(@result["data"], @document.operation.selections, @supergraph.schema.query)
17
+ # hate doing a second pass, but cannot remove _STITCH_ fields until the fragements are processed
18
+ clean_entry(@result["data"])
19
+ end
20
+
21
+ if @errors.length > 0
22
+ @result = [] unless @result.key?("errors")
23
+ @result["errors"] += @errors
24
+ end
25
+
26
+ @result
27
+ end
28
+
29
+ private
30
+
31
+ def munge_entry(entry, selections, parent_type)
32
+ selections.each do |node|
33
+ case node
34
+ when GraphQL::Language::Nodes::Field
35
+ next if node.respond_to?(:name) && node&.name == "__typename"
36
+
37
+ munge_field(entry, node, parent_type)
38
+
39
+ when GraphQL::Language::Nodes::InlineFragment
40
+ next unless entry["_STITCH_typename"] == node.type.name
41
+ fragment_type = @supergraph.schema.types[node.type.name]
42
+ munge_entry(entry, node.selections, fragment_type)
43
+
44
+ when GraphQL::Language::Nodes::FragmentSpread
45
+ next unless entry["_STITCH_typename"] == node.name
46
+ fragment = @document.fragment_definitions[node.name]
47
+ fragment_type = @supergraph.schema.types[node.name]
48
+ munge_entry(entry, fragment.selections, fragment_type)
49
+ else
50
+ raise "Unexpected node of type #{node.class.name} in selection set."
51
+ end
52
+ end
53
+ end
54
+
55
+ def munge_field(entry, node, parent_type)
56
+ field_identifier = (node.alias || node.name)
57
+ node_type = Util.get_named_type_for_field_node(@supergraph.schema, parent_type, node)
58
+
59
+ if entry.nil?
60
+ return # TODO bubble up error if not nullable
61
+ end
62
+
63
+ child_entry = entry[field_identifier]
64
+ if child_entry.nil?
65
+ entry[field_identifier] = nil
66
+ elsif child_entry.is_a? Array
67
+ child_entry.each do |raw_item|
68
+ munge_entry(raw_item, node.selections, node_type)
69
+ end
70
+ elsif ! Util.is_leaf_type?(node_type)
71
+ munge_entry(child_entry, node.selections, node_type)
72
+ end
73
+ end
74
+
75
+ def clean_entry(entry)
76
+ return if entry.nil?
77
+
78
+ entry.each do |key, value|
79
+ if key.start_with? "_STITCH_"
80
+ entry.delete(key)
81
+ elsif value.is_a?(Array)
82
+ value.each do |item|
83
+ clean_entry(item)
84
+ end
85
+ elsif value.is_a?(Hash)
86
+ clean_entry(value)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Supergraph
6
+ LOCATION = "__super"
7
+ INTROSPECTION_TYPES = [
8
+ "__Schema",
9
+ "__Type",
10
+ "__Field",
11
+ "__Directive",
12
+ "__EnumValue",
13
+ "__InputValue",
14
+ "__TypeKind",
15
+ "__DirectiveLocation",
16
+ ].freeze
17
+
18
+ attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
19
+
20
+ def initialize(schema:, fields:, boundaries:, executables: {})
21
+ @schema = schema
22
+ @boundaries = boundaries
23
+ @locations_by_type_and_field = INTROSPECTION_TYPES.each_with_object(fields) do |type_name, memo|
24
+ introspection_type = schema.get_type(type_name)
25
+ next unless introspection_type.kind.fields?
26
+
27
+ memo[type_name] = introspection_type.fields.keys.each_with_object({}) do |field_name, m|
28
+ m[field_name] = [LOCATION]
29
+ end
30
+ end
31
+
32
+ @possible_keys_by_type_and_location = {}
33
+ @executables = { LOCATION => @schema }.merge!(executables)
34
+ end
35
+
36
+ def fields
37
+ @locations_by_type_and_field.reject { |k, _v| INTROSPECTION_TYPES.include?(k) }
38
+ end
39
+
40
+ def export
41
+ return GraphQL::Schema::Printer.print_schema(@schema), {
42
+ "fields" => fields,
43
+ "boundaries" => @boundaries,
44
+ }
45
+ end
46
+
47
+ def self.from_export(schema, delegation_map, executables: {})
48
+ schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
49
+ new(
50
+ schema: schema,
51
+ fields: delegation_map["fields"],
52
+ boundaries: delegation_map["boundaries"],
53
+ executables: executables,
54
+ )
55
+ end
56
+
57
+ def assign_executable(location, executable = nil, &block)
58
+ executable ||= block
59
+ unless executable.is_a?(Class) && executable <= GraphQL::Schema
60
+ raise "A client or block handler must be provided." unless executable
61
+ raise "A client must be callable" unless executable.respond_to?(:call)
62
+ end
63
+ @executables[location] = executable
64
+ end
65
+
66
+ def execute_at_location(location, query, variables)
67
+ executable = executables[location]
68
+
69
+ if executable.nil?
70
+ raise "No executable assigned for #{location} location."
71
+ elsif executable.is_a?(Class) && executable <= GraphQL::Schema
72
+ executable.execute(
73
+ query: query,
74
+ variables: variables,
75
+ validate: false,
76
+ )
77
+ elsif executable.respond_to?(:call)
78
+ executable.call(location, query, variables)
79
+ else
80
+ raise "Missing valid executable for #{location} location."
81
+ end
82
+ end
83
+
84
+ # inverts fields map to provide fields for a type/location
85
+ def fields_by_type_and_location
86
+ @fields_by_type_and_location ||= @locations_by_type_and_field.each_with_object({}) do |(type_name, fields), memo|
87
+ memo[type_name] = fields.each_with_object({}) do |(field_name, locations), memo|
88
+ locations.each do |location|
89
+ memo[location] ||= []
90
+ memo[location] << field_name
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ def locations_by_type
97
+ @locations_by_type ||= @locations_by_type_and_field.each_with_object({}) do |(type_name, fields), memo|
98
+ memo[type_name] = fields.values.flatten.uniq
99
+ end
100
+ end
101
+
102
+ def possible_keys_for_type_and_location(type_name, location)
103
+ possible_keys_by_type = @possible_keys_by_type_and_location[type_name] ||= {}
104
+ possible_keys_by_type[location] ||= begin
105
+ location_fields = fields_by_type_and_location[type_name][location] || []
106
+ location_fields & @boundaries[type_name].map { _1["selection"] }
107
+ end
108
+ end
109
+
110
+ # For a given type, route from one origin service to one or more remote locations.
111
+ # Tunes a-star search to favor paths with fewest joining locations, ie:
112
+ # favor longer paths through target locations over shorter paths with additional locations.
113
+ def route_type_to_locations(type_name, start_location, goal_locations)
114
+ results = {}
115
+ costs = {}
116
+
117
+ paths = possible_keys_for_type_and_location(type_name, start_location).map do |possible_key|
118
+ [{ location: start_location, selection: possible_key, cost: 0 }]
119
+ end
120
+
121
+ while paths.any?
122
+ path = paths.pop
123
+ current_location = path.last[:location]
124
+ current_selection = path.last[:selection]
125
+ current_cost = path.last[:cost]
126
+
127
+ @boundaries[type_name].each do |boundary|
128
+ forward_location = boundary["location"]
129
+ next if current_selection != boundary["selection"]
130
+ next if path.any? { _1[:location] == forward_location }
131
+
132
+ best_cost = costs[forward_location] || Float::INFINITY
133
+ next if best_cost < current_cost
134
+
135
+ path.pop
136
+ path << {
137
+ location: current_location,
138
+ selection: current_selection,
139
+ cost: current_cost,
140
+ boundary: boundary,
141
+ }
142
+
143
+ if goal_locations.include?(forward_location)
144
+ current_result = results[forward_location]
145
+ if current_result.nil? || current_cost < best_cost || (current_cost == best_cost && path.length < current_result.length)
146
+ results[forward_location] = path.map { _1[:boundary] }
147
+ end
148
+ else
149
+ path.last[:cost] += 1
150
+ end
151
+
152
+ forward_cost = path.last[:cost]
153
+ costs[forward_location] = forward_cost if forward_cost < best_cost
154
+
155
+ possible_keys_for_type_and_location(type_name, forward_location).each do |possible_key|
156
+ paths << [*path, { location: forward_location, selection: possible_key, cost: forward_cost }]
157
+ end
158
+ end
159
+
160
+ paths.sort! do |a, b|
161
+ cost_diff = a.last[:cost] - b.last[:cost]
162
+ next cost_diff unless cost_diff.zero?
163
+ a.length - b.length
164
+ end.reverse!
165
+ end
166
+
167
+ results
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Util
6
+
7
+ # gets the named type at the bottom of a non-null/list wrapper chain
8
+ def self.get_named_type(type)
9
+ while type.respond_to?(:of_type)
10
+ type = type.of_type
11
+ end
12
+ type
13
+ end
14
+
15
+ # gets a deep structural description of a list value type
16
+ def self.get_list_structure(type)
17
+ structure = []
18
+ previous = nil
19
+ while type.respond_to?(:of_type)
20
+ if type.is_a?(GraphQL::Schema::List)
21
+ structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_list" : "list")
22
+ end
23
+ if structure.any?
24
+ previous = type
25
+ if !type.of_type.respond_to?(:of_type)
26
+ structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_element" : "element")
27
+ end
28
+ end
29
+ type = type.of_type
30
+ end
31
+ structure
32
+ end
33
+
34
+ # Gets all objects and interfaces that implement a given interface
35
+ def self.get_possible_types(schema, parent_type)
36
+ return [parent_type] unless parent_type.kind.abstract?
37
+ return parent_type.possible_types if parent_type.kind.union?
38
+
39
+ result = []
40
+ schema.types.values.each do |type|
41
+ next unless type <= GraphQL::Schema::Interface && type != parent_type
42
+ next unless type.interfaces.include?(parent_type)
43
+ result << type
44
+ result.push(*get_possible_types(schema, type)) if type.kind.interface?
45
+ end
46
+ result.uniq
47
+ end
48
+
49
+ # Specifies if a type is a leaf node (no children)
50
+ def self.is_leaf_type?(type)
51
+ type.kind.scalar? || type.kind.enum?
52
+ end
53
+
54
+ def self.get_named_type_for_field_node(schema, parent_type, node)
55
+ if node.name == "__schema" && parent_type == schema.query
56
+ schema.types["__Schema"] # type mapped to phantom introspection field
57
+ else
58
+ Util.get_named_type(parent_type.fields[node.name].type)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql"
4
+
5
+ module GraphQL
6
+ module Stitching
7
+ class StitchingError < StandardError; end
8
+
9
+ class << self
10
+
11
+ def stitch_directive
12
+ @stitch_directive ||= "stitch"
13
+ end
14
+
15
+ attr_writer :stitch_directive
16
+ end
17
+ end
18
+ end
19
+
20
+ require_relative "stitching/gateway"
21
+ require_relative "stitching/supergraph"
22
+ require_relative "stitching/composer"
23
+ require_relative "stitching/document"
24
+ require_relative "stitching/executor"
25
+ require_relative "stitching/planner_operation"
26
+ require_relative "stitching/planner"
27
+ require_relative "stitching/remote_client"
28
+ require_relative "stitching/shaper"
29
+ require_relative "stitching/util"
30
+ require_relative "stitching/version"
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: graphql-stitching
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Greg MacWilliam
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-02-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: graphql
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.0.16
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.0.16
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '12.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '12.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.12'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.12'
69
+ description: GraphQL schema stitching for Ruby
70
+ email:
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - ".github/workflows/ci.yml"
76
+ - ".gitignore"
77
+ - ".ruby-version"
78
+ - Gemfile
79
+ - Gemfile.lock
80
+ - LICENSE
81
+ - Procfile
82
+ - README.md
83
+ - Rakefile
84
+ - docs/README.md
85
+ - docs/composer.md
86
+ - docs/document.md
87
+ - docs/executor.md
88
+ - docs/gateway.md
89
+ - docs/images/library.png
90
+ - docs/images/merging.png
91
+ - docs/images/stitching.png
92
+ - docs/planner.md
93
+ - docs/shaper.md
94
+ - docs/supergraph.md
95
+ - example/gateway.rb
96
+ - example/graphiql.html
97
+ - example/remote1.rb
98
+ - example/remote2.rb
99
+ - graphql-stitching.gemspec
100
+ - lib/graphql/stitching.rb
101
+ - lib/graphql/stitching/composer.rb
102
+ - lib/graphql/stitching/composer/base_validator.rb
103
+ - lib/graphql/stitching/composer/validate_boundaries.rb
104
+ - lib/graphql/stitching/composer/validate_interfaces.rb
105
+ - lib/graphql/stitching/document.rb
106
+ - lib/graphql/stitching/executor.rb
107
+ - lib/graphql/stitching/gateway.rb
108
+ - lib/graphql/stitching/planner.rb
109
+ - lib/graphql/stitching/planner_operation.rb
110
+ - lib/graphql/stitching/remote_client.rb
111
+ - lib/graphql/stitching/shaper.rb
112
+ - lib/graphql/stitching/supergraph.rb
113
+ - lib/graphql/stitching/util.rb
114
+ - lib/graphql/stitching/version.rb
115
+ homepage: https://github.com/gmac/graphql-stitching-ruby
116
+ licenses:
117
+ - MIT
118
+ metadata:
119
+ homepage_uri: https://github.com/gmac/graphql-stitching-ruby
120
+ changelog_uri: https://github.com/gmac/graphql-stitching-ruby/releases
121
+ source_code_uri: https://github.com/gmac/graphql-stitching-ruby
122
+ bug_tracker_uri: https://github.com/gmac/graphql-stitching-ruby/issues
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: 3.1.1
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 3.3.7
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: GraphQL schema stitching for Ruby
142
+ test_files: []