graphql-stitching 0.3.6 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Executor::BoundarySource < GraphQL::Dataloader::Source
6
+ def initialize(executor, location)
7
+ @executor = executor
8
+ @location = location
9
+ end
10
+
11
+ def fetch(ops)
12
+ origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
13
+ origin_set = op.path.reduce([@executor.data]) do |set, path_segment|
14
+ set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
15
+ end
16
+
17
+ if op.if_type
18
+ # operations planned around unused fragment conditions should not trigger requests
19
+ origin_set.select! { _1[SelectionHint.typename_node.alias] == op.if_type }
20
+ end
21
+
22
+ memo[op] = origin_set if origin_set.any?
23
+ end
24
+
25
+ if origin_sets_by_operation.any?
26
+ query_document, variable_names = build_document(origin_sets_by_operation, @executor.request.operation_name)
27
+ variables = @executor.request.variables.slice(*variable_names)
28
+ raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
29
+ @executor.query_count += 1
30
+
31
+ merge_results!(origin_sets_by_operation, raw_result.dig("data"))
32
+
33
+ errors = raw_result.dig("errors")
34
+ @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
35
+ end
36
+
37
+ ops.map { origin_sets_by_operation[_1] ? _1.step : nil }
38
+ end
39
+
40
+ # Builds batched boundary queries
41
+ # "query MyOperation_2_3($var:VarType) {
42
+ # _0_result: list(keys:["a","b","c"]) { boundarySelections... }
43
+ # _1_0_result: item(key:"x") { boundarySelections... }
44
+ # _1_1_result: item(key:"y") { boundarySelections... }
45
+ # _1_2_result: item(key:"z") { boundarySelections... }
46
+ # }"
47
+ def build_document(origin_sets_by_operation, operation_name = nil)
48
+ variable_defs = {}
49
+ query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
50
+ variable_defs.merge!(op.variables)
51
+ boundary = op.boundary
52
+
53
+ if boundary.list
54
+ input = origin_set.each_with_index.reduce(String.new) do |memo, (origin_obj, index)|
55
+ memo << "," if index > 0
56
+ memo << build_key(boundary.key, origin_obj, federation: boundary.federation)
57
+ memo
58
+ end
59
+
60
+ "_#{batch_index}_result: #{boundary.field}(#{boundary.arg}:[#{input}]) #{op.selections}"
61
+ else
62
+ origin_set.map.with_index do |origin_obj, index|
63
+ input = build_key(boundary.key, origin_obj, federation: boundary.federation)
64
+ "_#{batch_index}_#{index}_result: #{boundary.field}(#{boundary.arg}:#{input}) #{op.selections}"
65
+ end
66
+ end
67
+ end
68
+
69
+ doc = String.new("query") # << boundary fulfillment always uses query
70
+
71
+ if operation_name
72
+ doc << " #{operation_name}"
73
+ origin_sets_by_operation.each_key do |op|
74
+ doc << "_#{op.step}"
75
+ end
76
+ end
77
+
78
+ if variable_defs.any?
79
+ variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
80
+ doc << "(#{variable_str})"
81
+ end
82
+
83
+ doc << "{ #{query_fields.join(" ")} }"
84
+
85
+ return doc, variable_defs.keys
86
+ end
87
+
88
+ def build_key(key, origin_obj, federation: false)
89
+ key_value = JSON.generate(origin_obj[SelectionHint.key(key)])
90
+ if federation
91
+ "{ __typename: \"#{origin_obj[SelectionHint.typename_node.alias]}\", #{key}: #{key_value} }"
92
+ else
93
+ key_value
94
+ end
95
+ end
96
+
97
+ def merge_results!(origin_sets_by_operation, raw_result)
98
+ return unless raw_result
99
+
100
+ origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index|
101
+ results = if op.dig("boundary", "list")
102
+ raw_result["_#{batch_index}_result"]
103
+ else
104
+ origin_set.map.with_index { |_, index| raw_result["_#{batch_index}_#{index}_result"] }
105
+ end
106
+
107
+ next unless results&.any?
108
+
109
+ origin_set.each_with_index do |origin_obj, index|
110
+ origin_obj.merge!(results[index]) if results[index]
111
+ end
112
+ end
113
+ end
114
+
115
+ # https://spec.graphql.org/June2018/#sec-Errors
116
+ def extract_errors!(origin_sets_by_operation, errors)
117
+ ops = origin_sets_by_operation.keys
118
+ origin_sets = origin_sets_by_operation.values
119
+ pathed_errors_by_op_index_and_object_id = {}
120
+
121
+ errors_result = errors.each_with_object([]) do |err, memo|
122
+ err.delete("locations")
123
+ path = err["path"]
124
+
125
+ if path && path.length > 0
126
+ result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s)
127
+
128
+ if result_alias
129
+ path = err["path"] = path[1..-1]
130
+
131
+ origin_obj = if result_alias[2]
132
+ origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i)
133
+ elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s)
134
+ origin_sets.dig(result_alias[1].to_i, path.shift.to_i)
135
+ end
136
+
137
+ if origin_obj
138
+ by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i] ||= {}
139
+ by_object_id = by_op_index[origin_obj.object_id] ||= []
140
+ by_object_id << err
141
+ next
142
+ end
143
+ end
144
+ end
145
+
146
+ memo << err
147
+ end
148
+
149
+ if pathed_errors_by_op_index_and_object_id.any?
150
+ pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
151
+ repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "path"))
152
+ errors_result.concat(pathed_errors_by_object_id.values)
153
+ end
154
+ end
155
+ errors_result.flatten!
156
+ end
157
+
158
+ private
159
+
160
+ # traverse forward through origin data, expanding arrays to follow all paths
161
+ # any errors found for an origin object_id have their path prefixed by the object path
162
+ def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data)
163
+ current_path.push(forward_path.shift)
164
+ scope = root[current_path.last]
165
+
166
+ if forward_path.any? && scope.is_a?(Array)
167
+ scope.each_with_index do |element, index|
168
+ inner_elements = element.is_a?(Array) ? element.flatten : [element]
169
+ inner_elements.each do |inner_element|
170
+ current_path << index
171
+ repath_errors!(pathed_errors_by_object_id, forward_path, current_path, inner_element)
172
+ current_path.pop
173
+ end
174
+ end
175
+
176
+ elsif forward_path.any?
177
+ current_path << index
178
+ repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope)
179
+ current_path.pop
180
+
181
+ elsif scope.is_a?(Array)
182
+ scope.each_with_index do |element, index|
183
+ inner_elements = element.is_a?(Array) ? element.flatten : [element]
184
+ inner_elements.each do |inner_element|
185
+ errors = pathed_errors_by_object_id[inner_element.object_id]
186
+ errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors
187
+ end
188
+ end
189
+
190
+ else
191
+ errors = pathed_errors_by_object_id[scope.object_id]
192
+ errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors
193
+ end
194
+
195
+ forward_path.unshift(current_path.pop)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Executor::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(op, @executor.request.operation_name)
15
+ query_variables = @executor.request.variables.slice(*op.variables.keys)
16
+ result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request.context)
17
+ @executor.query_count += 1
18
+
19
+ @executor.data.merge!(result["data"]) if result["data"]
20
+ if result["errors"]&.any?
21
+ result["errors"].each { _1.delete("locations") }
22
+ @executor.errors.concat(result["errors"])
23
+ end
24
+
25
+ ops.map(&:step)
26
+ end
27
+
28
+ # Builds root source documents
29
+ # "query MyOperation_1($var:VarType) { rootSelections ... }"
30
+ def build_document(op, operation_name = nil)
31
+ doc = String.new
32
+ doc << op.operation_type
33
+
34
+ if operation_name
35
+ doc << " #{operation_name}_#{op.step}"
36
+ end
37
+
38
+ if op.variables.any?
39
+ variable_defs = op.variables.map { |k, v| "$#{k}:#{v}" }.join(",")
40
+ doc << "(#{variable_defs})"
41
+ end
42
+
43
+ doc << op.selections
44
+ doc
45
+ end
46
+ end
47
+ end
48
+ end
@@ -5,239 +5,13 @@ require "json"
5
5
  module GraphQL
6
6
  module Stitching
7
7
  class Executor
8
-
9
- class RootSource < GraphQL::Dataloader::Source
10
- def initialize(executor, location)
11
- @executor = executor
12
- @location = location
13
- end
14
-
15
- def fetch(ops)
16
- op = ops.first # There should only ever be one per location at a time
17
-
18
- query_document = build_document(op, @executor.request.operation_name)
19
- query_variables = @executor.request.variables.slice(*op["variables"].keys)
20
- result = @executor.supergraph.execute_at_location(op["location"], query_document, query_variables, @executor.request.context)
21
- @executor.query_count += 1
22
-
23
- @executor.data.merge!(result["data"]) if result["data"]
24
- if result["errors"]&.any?
25
- result["errors"].each { _1.delete("locations") }
26
- @executor.errors.concat(result["errors"])
27
- end
28
-
29
- ops.map { op["order"] }
30
- end
31
-
32
- # Builds root source documents
33
- # "query MyOperation_1($var:VarType) { rootSelections ... }"
34
- def build_document(op, operation_name = nil)
35
- doc = String.new
36
- doc << op["operation_type"]
37
-
38
- if operation_name
39
- doc << " #{operation_name}_#{op["order"]}"
40
- end
41
-
42
- if op["variables"].any?
43
- variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
44
- doc << "(#{variable_defs})"
45
- end
46
-
47
- doc << op["selections"]
48
- doc
49
- end
50
- end
51
-
52
- class BoundarySource < GraphQL::Dataloader::Source
53
- def initialize(executor, location)
54
- @executor = executor
55
- @location = location
56
- end
57
-
58
- def fetch(ops)
59
- origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
60
- origin_set = op["path"].reduce([@executor.data]) do |set, path_segment|
61
- set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
62
- end
63
-
64
- if op["if_type"]
65
- # operations planned around unused fragment conditions should not trigger requests
66
- origin_set.select! { _1["_STITCH_typename"] == op["if_type"] }
67
- end
68
-
69
- memo[op] = origin_set if origin_set.any?
70
- end
71
-
72
- if origin_sets_by_operation.any?
73
- query_document, variable_names = build_document(origin_sets_by_operation, @executor.request.operation_name)
74
- variables = @executor.request.variables.slice(*variable_names)
75
- raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
76
- @executor.query_count += 1
77
-
78
- merge_results!(origin_sets_by_operation, raw_result.dig("data"))
79
-
80
- errors = raw_result.dig("errors")
81
- @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
82
- end
83
-
84
- ops.map { origin_sets_by_operation[_1] ? _1["order"] : nil }
85
- end
86
-
87
- # Builds batched boundary queries
88
- # "query MyOperation_2_3($var:VarType) {
89
- # _0_result: list(keys:["a","b","c"]) { boundarySelections... }
90
- # _1_0_result: item(key:"x") { boundarySelections... }
91
- # _1_1_result: item(key:"y") { boundarySelections... }
92
- # _1_2_result: item(key:"z") { boundarySelections... }
93
- # }"
94
- def build_document(origin_sets_by_operation, operation_name = nil)
95
- variable_defs = {}
96
- query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
97
- variable_defs.merge!(op["variables"])
98
- boundary = op["boundary"]
99
- key_selection = "_STITCH_#{boundary["key"]}"
100
-
101
- if boundary["list"]
102
- input = JSON.generate(origin_set.map { _1[key_selection] })
103
- "_#{batch_index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
104
- else
105
- origin_set.map.with_index do |origin_obj, index|
106
- input = JSON.generate(origin_obj[key_selection])
107
- "_#{batch_index}_#{index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
108
- end
109
- end
110
- end
111
-
112
- doc = String.new
113
- doc << "query" # << boundary fulfillment always uses query
114
-
115
- if operation_name
116
- doc << " #{operation_name}"
117
- origin_sets_by_operation.each_key do |op|
118
- doc << "_#{op["order"]}"
119
- end
120
- end
121
-
122
- if variable_defs.any?
123
- variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
124
- doc << "(#{variable_str})"
125
- end
126
-
127
- doc << "{ #{query_fields.join(" ")} }"
128
-
129
- return doc, variable_defs.keys
130
- end
131
-
132
- def merge_results!(origin_sets_by_operation, raw_result)
133
- return unless raw_result
134
-
135
- origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index|
136
- results = if op.dig("boundary", "list")
137
- raw_result["_#{batch_index}_result"]
138
- else
139
- origin_set.map.with_index { |_, index| raw_result["_#{batch_index}_#{index}_result"] }
140
- end
141
-
142
- next unless results&.any?
143
-
144
- origin_set.each_with_index do |origin_obj, index|
145
- origin_obj.merge!(results[index]) if results[index]
146
- end
147
- end
148
- end
149
-
150
- # https://spec.graphql.org/June2018/#sec-Errors
151
- def extract_errors!(origin_sets_by_operation, errors)
152
- ops = origin_sets_by_operation.keys
153
- origin_sets = origin_sets_by_operation.values
154
- pathed_errors_by_op_index_and_object_id = {}
155
-
156
- errors_result = errors.each_with_object([]) do |err, memo|
157
- err.delete("locations")
158
- path = err["path"]
159
-
160
- if path && path.length > 0
161
- result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s)
162
-
163
- if result_alias
164
- path = err["path"] = path[1..-1]
165
-
166
- origin_obj = if result_alias[2]
167
- origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i)
168
- elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s)
169
- origin_sets.dig(result_alias[1].to_i, path.shift.to_i)
170
- end
171
-
172
- if origin_obj
173
- by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i] ||= {}
174
- by_object_id = by_op_index[origin_obj.object_id] ||= []
175
- by_object_id << err
176
- next
177
- end
178
- end
179
- end
180
-
181
- memo << err
182
- end
183
-
184
- if pathed_errors_by_op_index_and_object_id.any?
185
- pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
186
- repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "path"))
187
- errors_result.concat(pathed_errors_by_object_id.values)
188
- end
189
- end
190
- errors_result.flatten!
191
- end
192
-
193
- private
194
-
195
- # traverse forward through origin data, expanding arrays to follow all paths
196
- # any errors found for an origin object_id have their path prefixed by the object path
197
- def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data)
198
- current_path.push(forward_path.shift)
199
- scope = root[current_path.last]
200
-
201
- if forward_path.any? && scope.is_a?(Array)
202
- scope.each_with_index do |element, index|
203
- inner_elements = element.is_a?(Array) ? element.flatten : [element]
204
- inner_elements.each do |inner_element|
205
- current_path << index
206
- repath_errors!(pathed_errors_by_object_id, forward_path, current_path, inner_element)
207
- current_path.pop
208
- end
209
- end
210
-
211
- elsif forward_path.any?
212
- current_path << index
213
- repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope)
214
- current_path.pop
215
-
216
- elsif scope.is_a?(Array)
217
- scope.each_with_index do |element, index|
218
- inner_elements = element.is_a?(Array) ? element.flatten : [element]
219
- inner_elements.each do |inner_element|
220
- errors = pathed_errors_by_object_id[inner_element.object_id]
221
- errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors
222
- end
223
- end
224
-
225
- else
226
- errors = pathed_errors_by_object_id[scope.object_id]
227
- errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors
228
- end
229
-
230
- forward_path.unshift(current_path.pop)
231
- end
232
- end
233
-
234
8
  attr_reader :supergraph, :request, :data, :errors
235
9
  attr_accessor :query_count
236
10
 
237
11
  def initialize(supergraph:, request:, plan:, nonblocking: false)
238
12
  @supergraph = supergraph
239
13
  @request = request
240
- @queue = plan["ops"]
14
+ @queue = plan.ops
241
15
  @data = {}
242
16
  @errors = []
243
17
  @query_count = 0
@@ -273,8 +47,8 @@ module GraphQL
273
47
 
274
48
  @dataloader.append_job do
275
49
  tasks = @queue
276
- .select { next_ordinals.include?(_1["after"]) }
277
- .group_by { [_1["location"], _1["boundary"].nil?] }
50
+ .select { next_ordinals.include?(_1.after) }
51
+ .group_by { [_1.location, _1.boundary.nil?] }
278
52
  .map do |(location, root_source), ops|
279
53
  if root_source
280
54
  @dataloader.with(RootSource, self, location).request_all(ops)
@@ -297,3 +71,6 @@ module GraphQL
297
71
  end
298
72
  end
299
73
  end
74
+
75
+ require_relative "./executor/boundary_source"
76
+ require_relative "./executor/root_source"
@@ -6,7 +6,7 @@ require "json"
6
6
 
7
7
  module GraphQL
8
8
  module Stitching
9
- class RemoteClient
9
+ class HttpExecutable
10
10
  def initialize(url:, headers:{})
11
11
  @url = url
12
12
  @headers = { "Content-Type" => "application/json" }.merge!(headers)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Plan
6
+ Op = Struct.new(
7
+ :step,
8
+ :after,
9
+ :location,
10
+ :operation_type,
11
+ :selections,
12
+ :variables,
13
+ :path,
14
+ :if_type,
15
+ :boundary,
16
+ keyword_init: true
17
+ ) do
18
+ def as_json
19
+ {
20
+ step: step,
21
+ after: after,
22
+ location: location,
23
+ operation_type: operation_type,
24
+ selections: selections,
25
+ variables: variables,
26
+ path: path,
27
+ if_type: if_type,
28
+ boundary: boundary&.as_json
29
+ }.tap(&:compact!)
30
+ end
31
+ end
32
+
33
+ class << self
34
+ def from_json(json)
35
+ ops = json["ops"]
36
+ ops = ops.map do |op|
37
+ boundary = op["boundary"]
38
+ Op.new(
39
+ step: op["step"],
40
+ after: op["after"],
41
+ location: op["location"],
42
+ operation_type: op["operation_type"],
43
+ selections: op["selections"],
44
+ variables: op["variables"],
45
+ path: op["path"],
46
+ if_type: op["if_type"],
47
+ boundary: boundary ? GraphQL::Stitching::Boundary.new(**boundary) : nil,
48
+ )
49
+ end
50
+ new(ops: ops)
51
+ end
52
+ end
53
+
54
+ attr_reader :ops
55
+
56
+ def initialize(ops: [])
57
+ @ops = ops
58
+ end
59
+
60
+ def as_json
61
+ { ops: @ops.map(&:as_json) }
62
+ end
63
+ end
64
+ end
65
+ end