graphql-stitching 0.3.0 → 0.3.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f7587a903fe54feeffe79deb25fdcf341f13af67b46f896003dfdba861cb614
4
- data.tar.gz: 191ff2274244d23b16325579792fd07310bf5b3fc3cc1a2b20e7b30305456851
3
+ metadata.gz: 33a2615122207bdf93a7df873fe1efe7a7f3c13050f59dabc6e85becd75cf96e
4
+ data.tar.gz: 9f203624ab5c26a3cb6ab4710759ef7cb76c1890fff8f4849ab31404875fe79a
5
5
  SHA512:
6
- metadata.gz: 7fd408963db10a7bfdfc272987a793353a190a191eb5de264a1a6a0ccd7e80293605bb57e85c70c52f66a3cd646b966930741b938ca231146f527704b524f0a1
7
- data.tar.gz: 1857a8492545cc4d7da64c541c897bb14e898c391edf271baf54a5e39369d8f387d80500c40fc591726c2dcb06fcce3f1164a9680f266e9a22494da577076648
6
+ metadata.gz: ad1afa71d6525ea79857106eca6c347f77fab8141dd9a504f3da1a6631f598fe7fb154bc446e228e420fa46dff993140ff568fc3dc94af7a76f0cf29da550896
7
+ data.tar.gz: 2632af6a1347f1fb6513c79781c3959aacdd879583519cb31e69834a48f2ae70cafdb778d6915713d0d19bf535a67cac55763377cc27da0795b80ed6abc298d4
data/.gitignore CHANGED
@@ -25,6 +25,8 @@ Gemfile.lock
25
25
  .dat*
26
26
  .repl_history
27
27
  build/
28
+ node_modules/
29
+ package-lock.json
28
30
  *.bridgesupport
29
31
  build-iPhoneOS/
30
32
  build-iPhoneSimulator/
data/README.md CHANGED
@@ -32,7 +32,7 @@ require "graphql/stitching"
32
32
 
33
33
  ## Usage
34
34
 
35
- The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that assembles a stitched graph ready to execute requests:
35
+ The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/gateway.md#cache-hooks):
36
36
 
37
37
  ```ruby
38
38
  movies_schema = <<~GRAPHQL
@@ -305,13 +305,13 @@ class MyExecutable
305
305
  end
306
306
  ```
307
307
 
308
- A [Supergraph](./docs/supergraph.md) is composed with executable resource provided for each location. Any location that omits the `executable` option will use the provided `schema` as the default executable:
308
+ A [Supergraph](./docs/supergraph.md) is composed with executable resources provided for each location. Any location that omits the `executable` option will use the provided `schema` as its default executable:
309
309
 
310
310
  ```ruby
311
311
  supergraph = GraphQL::Stitching::Composer.new.perform({
312
312
  first: {
313
313
  schema: FirstSchema,
314
- # executable: ^^^^^ delegates to FirstSchema,
314
+ # executable:^^^^^^ delegates to FirstSchema,
315
315
  },
316
316
  second: {
317
317
  schema: SecondSchema,
@@ -33,8 +33,9 @@ module GraphQL
33
33
  # only one boundary allowed per type/location/key
34
34
  boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
35
35
  if memo.dig(boundary["location"], boundary["selection"])
36
- raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` found in #{boundary["location"]}.
37
- Limit one boundary query per type and key in each location. Abstract boundaries provide all possible types."
36
+ raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` "\
37
+ "found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
38
+ "Abstract boundaries provide all possible types."
38
39
  end
39
40
  memo[boundary["location"]] ||= {}
40
41
  memo[boundary["location"]][boundary["selection"]] = boundary
@@ -60,8 +61,8 @@ module GraphQL
60
61
  remote_locations = bidirectional_access_locations.reject { _1 == location }
61
62
  paths = ctx.route_type_to_locations(type.graphql_name, location, remote_locations)
62
63
  if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
63
- raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations.
64
- All locations must provide a boundary accessor that uses a conjoining key."
64
+ raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations. "\
65
+ "All locations must provide a boundary accessor that uses a conjoining key."
65
66
  end
66
67
  end
67
68
  end
@@ -80,8 +81,8 @@ module GraphQL
80
81
 
81
82
  subschema_types_by_location.each do |location, subschema_type|
82
83
  if subschema_type.fields.keys.sort != expected_fields
83
- raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations,
84
- or else define boundary queries so that its unique fields may be accessed remotely."
84
+ raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
85
+ "or else define boundary queries so that its unique fields may be accessed remotely."
85
86
  end
86
87
  end
87
88
  end
@@ -4,19 +4,47 @@ module GraphQL
4
4
  module Stitching
5
5
  class Composer::ValidateInterfaces < Composer::BaseValidator
6
6
 
7
+ # For each composed interface, check the interface against each possible type
8
+ # to assure that intersecting fields have compatible types, structures, and nullability.
9
+ # Verifies compatibility of types that inherit interface contracts through merging.
7
10
  def perform(supergraph, composer)
8
- # @todo
9
- # Validate all supergraph interface fields
10
- # match possible types in all locations...
11
- # - Traverse supergraph types (supergraph.types)
12
- # - For each interface (.kind.interface?), get possible types (Util.get_possible_types)
13
- # - For each possible type, traverse type candidates (composer.subschema_types_by_name_and_location)
14
- # - For each type candidate, compare interface fields to type candidate fields
15
- # - For each type candidate field that matches an interface field...
16
- # - Named types must match
17
- # - List structures must match
18
- # - Nullabilities must be >= interface field
19
- # - It's OKAY if a type candidate does not implement the full interface
11
+ supergraph.schema.types.each do |type_name, interface_type|
12
+ next unless interface_type.kind.interface?
13
+
14
+ supergraph.schema.possible_types(interface_type).each do |possible_type|
15
+ interface_type.fields.each do |field_name, interface_field|
16
+ # graphql-ruby will dynamically apply interface fields on a type implementation,
17
+ # so check the delegation map to assure that all materialized fields have resolver locations.
18
+ unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any?
19
+ raise Composer::ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
20
+ "which is required by interface #{interface_type.graphql_name}."
21
+ end
22
+
23
+ intersecting_field = possible_type.fields[field_name]
24
+ interface_type_structure = Util.flatten_type_structure(interface_field.type)
25
+ possible_type_structure = Util.flatten_type_structure(intersecting_field.type)
26
+
27
+ if possible_type_structure.length != interface_type_structure.length
28
+ raise Composer::ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
29
+ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
30
+ end
31
+
32
+ interface_type_structure.each_with_index do |interface_struct, index|
33
+ possible_struct = possible_type_structure[index]
34
+
35
+ if possible_struct[:name] != interface_struct[:name]
36
+ raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
37
+ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
38
+ end
39
+
40
+ if possible_struct[:null] && !interface_struct[:null]
41
+ raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
42
+ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
20
48
  end
21
49
 
22
50
  end
@@ -77,7 +77,7 @@ module GraphQL
77
77
  schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
78
78
  kinds = types_by_location.values.map { _1.kind.name }.uniq
79
79
 
80
- unless kinds.all? { _1 == kinds.first }
80
+ if kinds.length > 1
81
81
  raise ComposerError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}."
82
82
  end
83
83
 
@@ -96,7 +96,7 @@ module GraphQL
96
96
  when "INPUT_OBJECT"
97
97
  build_input_object_type(type_name, types_by_location)
98
98
  else
99
- raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kind}."
99
+ raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kinds.first}."
100
100
  end
101
101
  end
102
102
 
@@ -307,12 +307,13 @@ module GraphQL
307
307
  fields_by_name_location.each do |field_name, fields_by_location|
308
308
  value_types = fields_by_location.values.map(&:type)
309
309
 
310
+ type = merge_value_types(type_name, value_types, field_name: field_name)
310
311
  schema_field = owner.field(
311
312
  field_name,
312
313
  description: merge_descriptions(type_name, fields_by_location, field_name: field_name),
313
314
  deprecation_reason: merge_deprecations(type_name, fields_by_location, field_name: field_name),
314
- type: merge_value_types(type_name, value_types, field_name: field_name),
315
- null: !value_types.all?(&:non_null?),
315
+ type: Util.unwrap_non_null(type),
316
+ null: !type.non_null?,
316
317
  camelize: false,
317
318
  )
318
319
 
@@ -345,12 +346,13 @@ module GraphQL
345
346
  # Getting double args sometimes... why?
346
347
  return if owner.arguments.any? { _1.first == argument_name }
347
348
 
349
+ type = merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name)
348
350
  schema_argument = owner.argument(
349
351
  argument_name,
350
352
  description: merge_descriptions(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
351
353
  deprecation_reason: merge_deprecations(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
352
- type: merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name),
353
- required: value_types.any?(&:non_null?),
354
+ type: Util.unwrap_non_null(type),
355
+ required: type.non_null?,
354
356
  camelize: false,
355
357
  )
356
358
 
@@ -401,37 +403,32 @@ module GraphQL
401
403
 
402
404
  def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
403
405
  path = [type_name, field_name, argument_name].compact.join(".")
404
- named_types = type_candidates.map { _1.unwrap.graphql_name }.uniq
406
+ alt_structures = type_candidates.map { Util.flatten_type_structure(_1) }
407
+ basis_structure = alt_structures.shift
405
408
 
406
- unless named_types.all? { _1 == named_types.first }
407
- raise ComposerError, "Cannot compose mixed types at `#{path}`. Found: #{named_types.join(", ")}."
408
- end
409
-
410
- type = GraphQL::Schema::BUILT_IN_TYPES.fetch(named_types.first, build_type_binding(named_types.first))
411
- list_structures = type_candidates.map { Util.get_list_structure(_1) }
412
-
413
- if list_structures.any?(&:any?)
414
- if list_structures.any? { _1.length != list_structures.first.length }
409
+ alt_structures.each do |alt_structure|
410
+ if alt_structure.length != basis_structure.length
415
411
  raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
416
412
  end
417
413
 
418
- list_structures.each(&:reverse!)
419
- list_structures.first.each_with_index do |current, index|
420
- # input arguments use strongest nullability, readonly fields use weakest
421
- non_null = list_structures.public_send(argument_name ? :any? : :all?) do |list_structure|
422
- list_structure[index].start_with?("non_null")
423
- end
424
-
425
- case current
426
- when "list", "non_null_list"
427
- type = type.to_list_type
428
- type = type.to_non_null_type if non_null
429
- when "element", "non_null_element"
430
- type = type.to_non_null_type if non_null
431
- end
414
+ if alt_structure.last[:name] != basis_structure.last[:name]
415
+ raise ComposerError, "Cannot compose mixed types at `#{path}`."
432
416
  end
433
417
  end
434
418
 
419
+ type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
420
+ basis_structure.last[:name],
421
+ build_type_binding(basis_structure.last[:name])
422
+ )
423
+
424
+ basis_structure.reverse!.each_with_index do |basis, index|
425
+ rev_index = basis_structure.length - index - 1
426
+ non_null = alt_structures.each_with_object([!basis[:null]]) { |s, m| m << !s[rev_index][:null] }
427
+
428
+ type = type.to_list_type if basis[:list]
429
+ type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
430
+ end
431
+
435
432
  type
436
433
  end
437
434
 
@@ -459,7 +456,7 @@ module GraphQL
459
456
  types_by_location.each do |location, type_candidate|
460
457
  type_candidate.fields.each do |field_name, field_candidate|
461
458
  boundary_type_name = field_candidate.type.unwrap.graphql_name
462
- boundary_list = Util.get_list_structure(field_candidate.type)
459
+ boundary_structure = Util.flatten_type_structure(field_candidate.type)
463
460
 
464
461
  field_candidate.directives.each do |directive|
465
462
  next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
@@ -482,8 +479,8 @@ module GraphQL
482
479
  raise ComposerError, "Invalid boundary argument `#{argument_name}` for #{type_name}.#{field_name}."
483
480
  end
484
481
 
485
- argument_list = Util.get_list_structure(argument.type)
486
- if argument_list.length != boundary_list.length
482
+ argument_structure = Util.flatten_type_structure(argument.type)
483
+ if argument_structure.length != boundary_structure.length
487
484
  raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
488
485
  end
489
486
 
@@ -493,7 +490,7 @@ module GraphQL
493
490
  "selection" => key_selections[0].name,
494
491
  "field" => field_candidate.name,
495
492
  "arg" => argument_name,
496
- "list" => boundary_list.any?,
493
+ "list" => boundary_structure.first[:list],
497
494
  "type_name" => boundary_type_name,
498
495
  }
499
496
  end
@@ -35,9 +35,9 @@ module GraphQL
35
35
  return error_result(validation_errors) if validation_errors.any?
36
36
  end
37
37
 
38
- request.prepare!
39
-
40
38
  begin
39
+ request.prepare!
40
+
41
41
  plan = fetch_plan(request) do
42
42
  GraphQL::Stitching::Planner.new(
43
43
  supergraph: @supergraph,
@@ -20,13 +20,11 @@ module GraphQL
20
20
  end
21
21
 
22
22
  def operations
23
- ops = @operations_by_grouping.values
24
- ops.sort_by!(&:key)
25
- ops
23
+ @operations_by_grouping.values.sort_by!(&:key)
26
24
  end
27
25
 
28
26
  def to_h
29
- { "ops" => operations.map(&:to_h) }
27
+ { "ops" => operations.map!(&:to_h) }
30
28
  end
31
29
 
32
30
  private
@@ -53,18 +51,16 @@ module GraphQL
53
51
 
54
52
  when "mutation"
55
53
  parent_type = @supergraph.schema.mutation
56
- location_groups = []
57
54
 
58
- @request.operation.selections.reduce(nil) do |last_location, node|
55
+ location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
59
56
  # root fields currently just delegate to the last location that defined them; this should probably be smarter
60
57
  next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
61
58
 
62
- if next_location != last_location
63
- location_groups << { location: next_location, selections: [] }
59
+ if memo.none? || memo.last[:location] != next_location
60
+ memo << { location: next_location, selections: [] }
64
61
  end
65
62
 
66
- location_groups.last[:selections] << node
67
- next_location
63
+ memo.last[:selections] << node
68
64
  end
69
65
 
70
66
  location_groups.reduce(0) do |after_key, group|
@@ -144,7 +140,7 @@ module GraphQL
144
140
  implements_fragments = false
145
141
 
146
142
  if parent_type.kind.interface?
147
- expand_interface_selections(current_location, parent_type, input_selections)
143
+ input_selections = expand_interface_selections(current_location, parent_type, input_selections)
148
144
  end
149
145
 
150
146
  input_selections.each do |node|
@@ -252,7 +248,7 @@ module GraphQL
252
248
  possible_locations = possible_locations_by_field[node.name]
253
249
  preferred_location_score = 0
254
250
 
255
- # hill climbing selects highest scoring locations to use
251
+ # hill-climb to select highest scoring location for each field
256
252
  preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
257
253
  score = selections_by_location[possible_location] ? remote_selections.length : 0
258
254
  score += location_weights.fetch(possible_location, 0)
@@ -270,6 +266,8 @@ module GraphQL
270
266
  end
271
267
  end
272
268
 
269
+ # route from current location to target locations via boundary queries,
270
+ # then translate those routes into planner operations
273
271
  routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
274
272
  routes.values.each_with_object({}) do |route, ops_by_location|
275
273
  route.reduce(nil) do |parent_op, boundary|
@@ -328,8 +326,8 @@ module GraphQL
328
326
  local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
329
327
 
330
328
  expanded_selections = nil
331
- input_selections.reject! do |node|
332
- if node.is_a?(GraphQL::Language::Nodes::Field) && !local_interface_fields.include?(node.name)
329
+ input_selections = input_selections.reject do |node|
330
+ if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != "__typename" && !local_interface_fields.include?(node.name)
333
331
  expanded_selections ||= []
334
332
  expanded_selections << node
335
333
  true
@@ -344,6 +342,8 @@ module GraphQL
344
342
  input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
345
343
  end
346
344
  end
345
+
346
+ input_selections
347
347
  end
348
348
 
349
349
  # expand concrete type selections into typed fragments when sending to abstract boundaries
@@ -3,6 +3,8 @@
3
3
  module GraphQL
4
4
  module Stitching
5
5
  class PlannerOperation
6
+ LANGUAGE_PRINTER = GraphQL::Language::Printer.new
7
+
6
8
  attr_reader :key, :location, :parent_type, :type_condition, :operation_type, :insertion_path
7
9
  attr_accessor :after_key, :selections, :variables, :boundary
8
10
 
@@ -32,12 +34,12 @@ module GraphQL
32
34
 
33
35
  def selection_set
34
36
  op = GraphQL::Language::Nodes::OperationDefinition.new(selections: @selections)
35
- GraphQL::Language::Printer.new.print(op).gsub!(/\s+/, " ").strip!
37
+ LANGUAGE_PRINTER.print(op).gsub!(/\s+/, " ").strip!
36
38
  end
37
39
 
38
40
  def variable_set
39
41
  @variables.each_with_object({}) do |(variable_name, value_type), memo|
40
- memo[variable_name] = GraphQL::Language::Printer.new.print(value_type)
42
+ memo[variable_name] = LANGUAGE_PRINTER.print(value_type)
41
43
  end
42
44
  end
43
45
 
@@ -42,15 +42,16 @@ module GraphQL
42
42
  return nil if raw_object[field_name].nil? && node_type.non_null?
43
43
 
44
44
  when GraphQL::Language::Nodes::InlineFragment
45
- next unless typename == node.type.name
46
45
  fragment_type = @schema.types[node.type.name]
46
+ next unless typename_in_type?(typename, fragment_type)
47
+
47
48
  result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
48
49
  return nil if result.nil?
49
50
 
50
51
  when GraphQL::Language::Nodes::FragmentSpread
51
52
  fragment = @request.fragment_definitions[node.name]
52
53
  fragment_type = @schema.types[fragment.type.name]
53
- next unless typename == fragment_type.graphql_name
54
+ next unless typename_in_type?(typename, fragment_type)
54
55
 
55
56
  result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
56
57
  return nil if result.nil?
@@ -91,6 +92,11 @@ module GraphQL
91
92
 
92
93
  resolved_list
93
94
  end
95
+
96
+ def typename_in_type?(typename, type)
97
+ return true if type.graphql_name == typename
98
+ type.kind.abstract? && @schema.possible_types(type).any? { _1.graphql_name == typename }
99
+ end
94
100
  end
95
101
  end
96
102
  end
@@ -10,12 +10,33 @@ module GraphQL
10
10
 
11
11
  # strips non-null wrappers from a type
12
12
  def self.unwrap_non_null(type)
13
- while type.is_a?(GraphQL::Schema::NonNull)
14
- type = type.of_type
15
- end
13
+ type = type.of_type while type.non_null?
16
14
  type
17
15
  end
18
16
 
17
+ # builds a single-dimensional representation of a wrapped type structure
18
+ def self.flatten_type_structure(type)
19
+ structure = []
20
+
21
+ while type.list?
22
+ structure << {
23
+ list: true,
24
+ null: !type.non_null?,
25
+ name: nil,
26
+ }
27
+
28
+ type = unwrap_non_null(type).of_type
29
+ end
30
+
31
+ structure << {
32
+ list: false,
33
+ null: !type.non_null?,
34
+ name: type.unwrap.graphql_name,
35
+ }
36
+
37
+ structure
38
+ end
39
+
19
40
  # gets a named type for a field node, including hidden root introspections
20
41
  def self.named_type_for_field_node(schema, parent_type, node)
21
42
  if node.name == "__schema" && parent_type == schema.query
@@ -40,25 +61,6 @@ module GraphQL
40
61
  end
41
62
  result.uniq
42
63
  end
43
-
44
- # gets a deep structural description of a list value type
45
- def self.get_list_structure(type)
46
- structure = []
47
- previous = nil
48
- while type.respond_to?(:of_type)
49
- if type.is_a?(GraphQL::Schema::List)
50
- structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_list" : "list")
51
- end
52
- if structure.any?
53
- previous = type
54
- if !type.of_type.respond_to?(:of_type)
55
- structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_element" : "element")
56
- end
57
- end
58
- type = type.of_type
59
- end
60
- structure
61
- end
62
64
  end
63
65
  end
64
66
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-02-27 00:00:00.000000000 Z
11
+ date: 2023-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql