graphql-stitching 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: []