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,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Composer
6
+ class ComposerError < StitchingError; end
7
+ class ValidationError < ComposerError; end
8
+
9
+ attr_reader :query_name, :mutation_name, :subschema_types_by_name_and_location
10
+
11
+ DEFAULT_STRING_MERGER = ->(str_by_location, _info) { str_by_location.values.find { !_1.nil? } }
12
+
13
+ VALIDATORS = [
14
+ "ValidateInterfaces",
15
+ "ValidateBoundaries",
16
+ ].freeze
17
+
18
+ def initialize(
19
+ schemas:,
20
+ query_name: "Query",
21
+ mutation_name: "Mutation",
22
+ description_merger: nil,
23
+ deprecation_merger: nil
24
+ )
25
+ @schemas = schemas
26
+ @query_name = query_name
27
+ @mutation_name = mutation_name
28
+ @field_map = {}
29
+ @boundary_map = {}
30
+ @mapped_type_names = {}
31
+
32
+ @description_merger = description_merger || DEFAULT_STRING_MERGER
33
+ @deprecation_merger = deprecation_merger || DEFAULT_STRING_MERGER
34
+ end
35
+
36
+ def perform
37
+ # "Typename" => "location" => candidate_type
38
+ @subschema_types_by_name_and_location = @schemas.each_with_object({}) do |(location, schema), memo|
39
+ raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
40
+ raise ComposerError, "The subscription operation is not supported." if schema.subscription
41
+
42
+ schema.types.each do |type_name, type_candidate|
43
+ next if Supergraph::INTROSPECTION_TYPES.include?(type_name)
44
+
45
+ if type_name == @query_name && type_candidate != schema.query
46
+ raise ComposerError, "Query name \"#{@query_name}\" is used by non-query type in #{location} schema."
47
+ elsif type_name == @mutation_name && type_candidate != schema.mutation
48
+ raise ComposerError, "Mutation name \"#{@mutation_name}\" is used by non-mutation type in #{location} schema."
49
+ end
50
+
51
+ type_name = @query_name if type_candidate == schema.query
52
+ type_name = @mutation_name if type_candidate == schema.mutation
53
+ @mapped_type_names[type_candidate.graphql_name] = type_name if type_candidate.graphql_name != type_name
54
+
55
+ memo[type_name] ||= {}
56
+ memo[type_name][location] = type_candidate
57
+ end
58
+ end
59
+
60
+ enum_usage = build_enum_usage_map(@schemas.values)
61
+
62
+ # "Typename" => merged_type
63
+ schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
64
+ kinds = types_by_location.values.map { _1.kind.name }.uniq
65
+
66
+ unless kinds.all? { _1 == kinds.first }
67
+ raise ComposerError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}."
68
+ end
69
+
70
+ memo[type_name] = case kinds.first
71
+ when "SCALAR"
72
+ build_scalar_type(type_name, types_by_location)
73
+ when "ENUM"
74
+ build_enum_type(type_name, types_by_location, enum_usage)
75
+ when "OBJECT"
76
+ extract_boundaries(type_name, types_by_location)
77
+ build_object_type(type_name, types_by_location)
78
+ when "INTERFACE"
79
+ build_interface_type(type_name, types_by_location)
80
+ when "UNION"
81
+ build_union_type(type_name, types_by_location)
82
+ when "INPUT_OBJECT"
83
+ build_input_object_type(type_name, types_by_location)
84
+ else
85
+ raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kind}."
86
+ end
87
+ end
88
+
89
+ builder = self
90
+ schema = Class.new(GraphQL::Schema) do
91
+ orphan_types schema_types.values
92
+ query schema_types[builder.query_name]
93
+ mutation schema_types[builder.mutation_name]
94
+
95
+ own_orphan_types.clear
96
+ end
97
+
98
+ expand_abstract_boundaries(schema)
99
+
100
+ supergraph = Supergraph.new(
101
+ schema: schema,
102
+ fields: @field_map,
103
+ boundaries: @boundary_map,
104
+ executables: @schemas,
105
+ )
106
+
107
+ VALIDATORS.each do |validator|
108
+ klass = Object.const_get("GraphQL::Stitching::Composer::#{validator}")
109
+ klass.new.perform(supergraph, self)
110
+ end
111
+
112
+ supergraph
113
+ end
114
+
115
+ def build_scalar_type(type_name, types_by_location)
116
+ built_in_type = GraphQL::Schema::BUILT_IN_TYPES[type_name]
117
+ return built_in_type if built_in_type
118
+
119
+ builder = self
120
+
121
+ Class.new(GraphQL::Schema::Scalar) do
122
+ graphql_name(type_name)
123
+ description(builder.merge_descriptions(type_name, types_by_location))
124
+ end
125
+ end
126
+
127
+ def build_enum_type(type_name, types_by_location, enum_usage)
128
+ builder = self
129
+
130
+ # "value" => "location" => enum_value
131
+ enum_values_by_value_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
132
+ type_candidate.enum_values.each do |enum_value_candidate|
133
+ memo[enum_value_candidate.value] ||= {}
134
+ memo[enum_value_candidate.value][location] ||= {}
135
+ memo[enum_value_candidate.value][location] = enum_value_candidate
136
+ end
137
+ end
138
+
139
+ # intersect input enum types
140
+ if enum_usage.fetch(type_name, []).include?(:write)
141
+ enum_values_by_value_location.reject! do |value, enum_values_by_location|
142
+ types_by_location.keys.length != enum_values_by_location.keys.length
143
+ end
144
+ end
145
+
146
+ Class.new(GraphQL::Schema::Enum) do
147
+ graphql_name(type_name)
148
+ description(builder.merge_descriptions(type_name, types_by_location))
149
+
150
+ enum_values_by_value_location.each do |value, enum_values_by_location|
151
+ value(value,
152
+ value: value,
153
+ description: builder.merge_descriptions(type_name, enum_values_by_location, enum_value: value),
154
+ deprecation_reason: builder.merge_deprecations(type_name, enum_values_by_location, enum_value: value),
155
+ )
156
+ end
157
+ end
158
+ end
159
+
160
+ def build_object_type(type_name, types_by_location)
161
+ builder = self
162
+
163
+ Class.new(GraphQL::Schema::Object) do
164
+ graphql_name(type_name)
165
+ description(builder.merge_descriptions(type_name, types_by_location))
166
+
167
+ interface_names = types_by_location.values.flat_map { _1.interfaces.map(&:graphql_name) }
168
+ interface_names.uniq.each do |interface_name|
169
+ implements(builder.build_type_binding(interface_name))
170
+ end
171
+
172
+ builder.build_merged_fields(type_name, types_by_location, self)
173
+ end
174
+ end
175
+
176
+ def build_interface_type(type_name, types_by_location)
177
+ builder = self
178
+
179
+ Module.new do
180
+ include GraphQL::Schema::Interface
181
+ graphql_name(type_name)
182
+ description(builder.merge_descriptions(type_name, types_by_location))
183
+
184
+ interface_names = types_by_location.values.flat_map { _1.interfaces.map(&:graphql_name) }
185
+ interface_names.uniq.each do |interface_name|
186
+ implements(builder.build_type_binding(interface_name))
187
+ end
188
+
189
+ builder.build_merged_fields(type_name, types_by_location, self)
190
+ end
191
+ end
192
+
193
+ def build_union_type(type_name, types_by_location)
194
+ builder = self
195
+
196
+ Class.new(GraphQL::Schema::Union) do
197
+ graphql_name(type_name)
198
+ description(builder.merge_descriptions(type_name, types_by_location))
199
+
200
+ possible_names = types_by_location.values.flat_map { _1.possible_types.map(&:graphql_name) }.uniq
201
+ possible_types(*possible_names.map { builder.build_type_binding(_1) })
202
+ end
203
+ end
204
+
205
+ def build_input_object_type(type_name, types_by_location)
206
+ builder = self
207
+
208
+ Class.new(GraphQL::Schema::InputObject) do
209
+ graphql_name(type_name)
210
+ description(builder.merge_descriptions(type_name, types_by_location))
211
+ builder.build_merged_arguments(type_name, types_by_location, self)
212
+ end
213
+ end
214
+
215
+ def build_type_binding(type_name)
216
+ GraphQL::Schema::LateBoundType.new(@mapped_type_names.fetch(type_name, type_name))
217
+ end
218
+
219
+ def build_merged_fields(type_name, types_by_location, owner)
220
+ # "field_name" => "location" => field
221
+ fields_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
222
+ @field_map[type_name] ||= {}
223
+ type_candidate.fields.each do |field_name, field_candidate|
224
+ @field_map[type_name][field_candidate.name] ||= []
225
+ @field_map[type_name][field_candidate.name] << location
226
+
227
+ memo[field_name] ||= {}
228
+ memo[field_name][location] ||= {}
229
+ memo[field_name][location] = field_candidate
230
+ end
231
+ end
232
+
233
+ fields_by_name_location.each do |field_name, fields_by_location|
234
+ value_types = fields_by_location.values.map(&:type)
235
+
236
+ schema_field = owner.field(
237
+ field_name,
238
+ description: merge_descriptions(type_name, fields_by_location, field_name: field_name),
239
+ deprecation_reason: merge_deprecations(type_name, fields_by_location, field_name: field_name),
240
+ type: merge_value_types(type_name, value_types, field_name: field_name),
241
+ null: !value_types.all?(&:non_null?),
242
+ camelize: false,
243
+ )
244
+
245
+ build_merged_arguments(type_name, fields_by_location, schema_field, field_name: field_name)
246
+ end
247
+ end
248
+
249
+ def build_merged_arguments(type_name, members_by_location, owner, field_name: nil)
250
+ # "argument_name" => "location" => argument
251
+ args_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
252
+ member_candidate.arguments.each do |argument_name, argument|
253
+ memo[argument_name] ||= {}
254
+ memo[argument_name][location] ||= {}
255
+ memo[argument_name][location] = argument
256
+ end
257
+ end
258
+
259
+ args_by_name_location.each do |argument_name, arguments_by_location|
260
+ value_types = arguments_by_location.values.map(&:type)
261
+
262
+ if arguments_by_location.length != members_by_location.length
263
+ if value_types.any?(&:non_null?)
264
+ path = [type_name, field_name, argument_name].compact.join(".")
265
+ raise ComposerError, "Required argument `#{path}` must be defined in all locations." # ...or hidden?
266
+ end
267
+ next
268
+ end
269
+
270
+ # Getting double args sometimes... why?
271
+ return if owner.arguments.any? { _1.first == argument_name }
272
+
273
+ owner.argument(
274
+ argument_name,
275
+ description: merge_descriptions(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
276
+ deprecation_reason: merge_deprecations(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
277
+ type: merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name),
278
+ required: value_types.any?(&:non_null?),
279
+ camelize: false,
280
+ )
281
+ end
282
+ end
283
+
284
+ def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
285
+ path = [type_name, field_name, argument_name].compact.join(".")
286
+ named_types = type_candidates.map { Util.get_named_type(_1).graphql_name }.uniq
287
+
288
+ unless named_types.all? { _1 == named_types.first }
289
+ raise ComposerError, "Cannot compose mixed types at `#{path}`. Found: #{named_types.join(", ")}."
290
+ end
291
+
292
+ type = GraphQL::Schema::BUILT_IN_TYPES.fetch(named_types.first, build_type_binding(named_types.first))
293
+ list_structures = type_candidates.map { Util.get_list_structure(_1) }
294
+
295
+ if list_structures.any?(&:any?)
296
+ if list_structures.any? { _1.length != list_structures.first.length }
297
+ raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
298
+ end
299
+
300
+ list_structures.each(&:reverse!)
301
+ list_structures.first.each_with_index do |current, index|
302
+ # input arguments use strongest nullability, readonly fields use weakest
303
+ non_null = list_structures.public_send(argument_name ? :any? : :all?) do |list_structure|
304
+ list_structure[index].start_with?("non_null")
305
+ end
306
+
307
+ case current
308
+ when "list", "non_null_list"
309
+ type = type.to_list_type
310
+ type = type.to_non_null_type if non_null
311
+ when "element", "non_null_element"
312
+ type = type.to_non_null_type if non_null
313
+ end
314
+ end
315
+ end
316
+
317
+ type
318
+ end
319
+
320
+ def merge_descriptions(type_name, members_by_location, field_name: nil, argument_name: nil, enum_value: nil)
321
+ strings_by_location = members_by_location.each_with_object({}) { |(l, m), memo| memo[l] = m.description }
322
+ @description_merger.call(strings_by_location, {
323
+ type_name: type_name,
324
+ field_name: field_name,
325
+ argument_name: argument_name,
326
+ enum_value: enum_value,
327
+ }.compact!)
328
+ end
329
+
330
+ def merge_deprecations(type_name, members_by_location, field_name: nil, argument_name: nil, enum_value: nil)
331
+ strings_by_location = members_by_location.each_with_object({}) { |(l, m), memo| memo[l] = m.deprecation_reason }
332
+ @deprecation_merger.call(strings_by_location, {
333
+ type_name: type_name,
334
+ field_name: field_name,
335
+ argument_name: argument_name,
336
+ enum_value: enum_value,
337
+ }.compact!)
338
+ end
339
+
340
+ def extract_boundaries(type_name, types_by_location)
341
+ types_by_location.each do |location, type_candidate|
342
+ type_candidate.fields.each do |field_name, field_candidate|
343
+ boundary_type_name = Util.get_named_type(field_candidate.type).graphql_name
344
+ boundary_list = Util.get_list_structure(field_candidate.type)
345
+
346
+ field_candidate.directives.each do |directive|
347
+ next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
348
+
349
+ key = directive.arguments.keyword_arguments.fetch(:key)
350
+ key_selections = GraphQL.parse("{ #{key} }").definitions[0].selections
351
+
352
+ if key_selections.length != 1
353
+ raise ComposerError, "Boundary key at #{type_name}.#{field_name} must specify exactly one key."
354
+ end
355
+
356
+ argument_name = key_selections[0].alias
357
+ argument_name ||= if field_candidate.arguments.size == 1
358
+ field_candidate.arguments.keys.first
359
+ end
360
+
361
+ argument = field_candidate.arguments[argument_name]
362
+ unless argument
363
+ # contextualize this... "boundaries with multiple args need mapping aliases."
364
+ raise ComposerError, "Invalid boundary argument `#{argument_name}` for #{type_name}.#{field_name}."
365
+ end
366
+
367
+ argument_list = Util.get_list_structure(argument.type)
368
+ if argument_list.length != boundary_list.length
369
+ raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
370
+ end
371
+
372
+ @boundary_map[boundary_type_name] ||= []
373
+ @boundary_map[boundary_type_name] << {
374
+ "location" => location,
375
+ "selection" => key_selections[0].name,
376
+ "field" => field_candidate.name,
377
+ "arg" => argument_name,
378
+ "list" => boundary_list.any?,
379
+ "type_name" => boundary_type_name,
380
+ }
381
+ end
382
+ end
383
+ end
384
+ end
385
+
386
+ def expand_abstract_boundaries(schema)
387
+ @boundary_map.keys.each do |type_name|
388
+ boundary_type = schema.types[type_name]
389
+ next unless boundary_type.kind.abstract?
390
+
391
+ possible_types = Util.get_possible_types(schema, boundary_type)
392
+ possible_types.select { @subschema_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |possible_type|
393
+ @boundary_map[possible_type.graphql_name] ||= []
394
+ @boundary_map[possible_type.graphql_name].push(*@boundary_map[type_name])
395
+ end
396
+ end
397
+ end
398
+
399
+ def build_enum_usage_map(schemas)
400
+ reads = []
401
+ writes = []
402
+
403
+ schemas.each do |schema|
404
+ schema.types.values.each do |type|
405
+ next if Supergraph::INTROSPECTION_TYPES.include?(type.graphql_name)
406
+
407
+ if type.kind.object? || type.kind.interface?
408
+ type.fields.values.each do |field|
409
+ field_type = Util.get_named_type(field.type)
410
+ reads << field_type.graphql_name if field_type.kind.enum?
411
+
412
+ field.arguments.values.each do |argument|
413
+ argument_type = Util.get_named_type(argument.type)
414
+ writes << argument_type.graphql_name if argument_type.kind.enum?
415
+ end
416
+ end
417
+
418
+ elsif type.kind.input_object?
419
+ type.arguments.values.each do |argument|
420
+ argument_type = Util.get_named_type(argument.type)
421
+ writes << argument_type.graphql_name if argument_type.kind.enum?
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ usage = reads.uniq.each_with_object({}) do |enum_name, memo|
428
+ memo[enum_name] ||= []
429
+ memo[enum_name] << :read
430
+ end
431
+ writes.uniq.each_with_object(usage) do |enum_name, memo|
432
+ memo[enum_name] ||= []
433
+ memo[enum_name] << :write
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end
439
+
440
+ require_relative "./composer/base_validator"
441
+ require_relative "./composer/validate_interfaces"
442
+ require_relative "./composer/validate_boundaries"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Document
6
+ SUPPORTED_OPERATIONS = ["query", "mutation"].freeze
7
+
8
+ attr_reader :ast, :operation_name
9
+
10
+ def initialize(string_or_ast, operation_name: nil)
11
+ @ast = if string_or_ast.is_a?(String)
12
+ GraphQL.parse(string_or_ast)
13
+ else
14
+ string_or_ast
15
+ end
16
+
17
+ @operation_name = operation_name
18
+ end
19
+
20
+ def string
21
+ @string ||= GraphQL::Language::Printer.new.print(@ast)
22
+ end
23
+
24
+ def digest
25
+ @digest ||= Digest::SHA2.hexdigest(string)
26
+ end
27
+
28
+ def operation
29
+ @operation ||= begin
30
+ operation_defs = @ast.definitions.select do |d|
31
+ next unless d.is_a?(GraphQL::Language::Nodes::OperationDefinition)
32
+ next unless SUPPORTED_OPERATIONS.include?(d.operation_type)
33
+ @operation_name ? d.name == @operation_name : true
34
+ end
35
+
36
+ if operation_defs.length < 1
37
+ raise GraphQL::ExecutionError, "Invalid root operation."
38
+ elsif operation_defs.length > 1
39
+ raise GraphQL::ExecutionError, "An operation name is required when sending multiple operations."
40
+ end
41
+
42
+ operation_defs.first
43
+ end
44
+ end
45
+
46
+ def variable_definitions
47
+ @variable_definitions ||= operation.variables.each_with_object({}) do |v, memo|
48
+ memo[v.name] = v.type
49
+ end
50
+ end
51
+
52
+ def fragment_definitions
53
+ @fragment_definitions ||= @ast.definitions.each_with_object({}) do |d, memo|
54
+ memo[d.name] = d if d.is_a?(GraphQL::Language::Nodes::FragmentDefinition)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end