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,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