graphql-stitching 1.3.0 → 1.4.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.
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Resolver
5
+ # Defines a single resolver argument structure
6
+ # @api private
7
+ class Argument
8
+ attr_reader :name
9
+ attr_reader :value
10
+ attr_reader :type_name
11
+
12
+ def initialize(name:, value:, list: false, type_name: nil)
13
+ @name = name
14
+ @value = value
15
+ @list = list
16
+ @type_name = type_name
17
+ end
18
+
19
+ def list?
20
+ @list
21
+ end
22
+
23
+ def key?
24
+ value.key?
25
+ end
26
+
27
+ def verify_key(key)
28
+ if key?
29
+ value.verify_key(self, key)
30
+ true
31
+ else
32
+ false
33
+ end
34
+ end
35
+
36
+ def ==(other)
37
+ self.class == other.class &&
38
+ @name == other.name &&
39
+ @value == other.value &&
40
+ @type_name == other.type_name &&
41
+ @list == other.list?
42
+ end
43
+
44
+ def build(origin_obj)
45
+ value.build(origin_obj)
46
+ end
47
+
48
+ def print
49
+ "#{name}: #{value.print}"
50
+ end
51
+
52
+ def to_definition
53
+ print.gsub(%|"|, "'")
54
+ end
55
+
56
+ alias_method :to_s, :to_definition
57
+
58
+ def to_type_definition
59
+ "#{name}: #{to_type_signature}"
60
+ end
61
+
62
+ def to_type_signature
63
+ # need to derive nullability...
64
+ list? ? "[#{@type_name}!]!" : "#{@type_name}!"
65
+ end
66
+ end
67
+
68
+ # An abstract argument input value
69
+ # @api private
70
+ class ArgumentValue
71
+ attr_reader :value
72
+
73
+ def initialize(value)
74
+ @value = value
75
+ end
76
+
77
+ def key?
78
+ false
79
+ end
80
+
81
+ def verify_key(arg, key)
82
+ nil
83
+ end
84
+
85
+ def ==(other)
86
+ self.class == other.class && value == other.value
87
+ end
88
+
89
+ def build(origin_obj)
90
+ value
91
+ end
92
+
93
+ def print
94
+ value
95
+ end
96
+ end
97
+
98
+ # An object input value
99
+ # @api private
100
+ class ObjectArgumentValue < ArgumentValue
101
+ def key?
102
+ value.any?(&:key?)
103
+ end
104
+
105
+ def verify_key(arg, key)
106
+ value.each { _1.verify_key(key) }
107
+ end
108
+
109
+ def build(origin_obj)
110
+ value.each_with_object({}) do |arg, memo|
111
+ memo[arg.name] = arg.build(origin_obj)
112
+ end
113
+ end
114
+
115
+ def print
116
+ "{#{value.map(&:print).join(", ")}}"
117
+ end
118
+ end
119
+
120
+ # A key input value
121
+ # @api private
122
+ class KeyArgumentValue < ArgumentValue
123
+ def initialize(value)
124
+ super(Array(value))
125
+ end
126
+
127
+ def key?
128
+ true
129
+ end
130
+
131
+ def verify_key(arg, key)
132
+ key_field = value.reduce(Resolver::KeyField.new("", inner: key)) do |field, ns|
133
+ if ns == Resolver::TYPE_NAME
134
+ Resolver::KeyField.new(Resolver::TYPE_NAME)
135
+ elsif field
136
+ field.inner.find { _1.name == ns }
137
+ end
138
+ end
139
+
140
+ # still not capturing enough type information to accurately compare key/arg types...
141
+ # best we can do for now is to verify the argument insertion matches a key path.
142
+ if key_field.nil?
143
+ raise CompositionError, "Argument `#{arg.name}: #{print}` cannot insert key `#{key.to_definition}`."
144
+ end
145
+ end
146
+
147
+ def build(origin_obj)
148
+ value.each_with_index.reduce(origin_obj) do |obj, (ns, idx)|
149
+ obj[idx.zero? ? Resolver.export_key(ns) : ns]
150
+ end
151
+ end
152
+
153
+ def print
154
+ "$.#{value.join(".")}"
155
+ end
156
+ end
157
+
158
+ # A typed enum input value
159
+ # @api private
160
+ class EnumArgumentValue < ArgumentValue
161
+ end
162
+
163
+ # A primitive input value literal
164
+ # @api private
165
+ class LiteralArgumentValue < ArgumentValue
166
+ def print
167
+ JSON.generate(value)
168
+ end
169
+ end
170
+
171
+ # Parser for building argument templates into resolver structures
172
+ # @api private
173
+ module ArgumentsParser
174
+ # Parses an argument template string into resolver arguments via schema casting.
175
+ # @param template [String] the template string to parse.
176
+ # @param field_def [GraphQL::Schema::FieldDefinition] a field definition providing arguments schema.
177
+ # @return [[GraphQL::Stitching::Resolver::Argument]] an array of resolver arguments.
178
+ def parse_arguments_with_field(template, field_def)
179
+ ast = parse_arg_defs(template)
180
+ args = build_argument_set(ast, field_def.arguments)
181
+ args.each do |arg|
182
+ next unless arg.key?
183
+
184
+ if field_def.type.list? && !arg.list?
185
+ raise CompositionError, "Cannot use repeatable key for `#{field_def.owner.graphql_name}.#{field_def.graphql_name}` " \
186
+ "in non-list argument `#{arg.name}`."
187
+ elsif !field_def.type.list? && arg.list?
188
+ raise CompositionError, "Cannot use non-repeatable key for `#{field_def.owner.graphql_name}.#{field_def.graphql_name}` " \
189
+ "in list argument `#{arg.name}`."
190
+ end
191
+ end
192
+
193
+ args
194
+ end
195
+
196
+ # Parses an argument template string into resolver arguments via SDL casting.
197
+ # @param template [String] the template string to parse.
198
+ # @param type_defs [String] the type definition string declaring argument types.
199
+ # @return [[GraphQL::Stitching::Resolver::Argument]] an array of resolver arguments.
200
+ def parse_arguments_with_type_defs(template, type_defs)
201
+ type_map = parse_type_defs(type_defs)
202
+ parse_arg_defs(template).map { build_argument(_1, type_struct: type_map[_1.name]) }
203
+ end
204
+
205
+ private
206
+
207
+ def parse_arg_defs(template)
208
+ template = template
209
+ .gsub("'", %|"|) # 'sfoo' -> "sfoo"
210
+ .gsub(/(\$[\w\.]+)/) { %|"#{_1}"| } # $.key -> "$.key"
211
+ .tap(&:strip!)
212
+
213
+ template = template[1..-2] if template.start_with?("(") && template.end_with?(")")
214
+
215
+ GraphQL.parse("{ f(#{template}) }")
216
+ .definitions.first
217
+ .selections.first
218
+ .arguments
219
+ end
220
+
221
+ def parse_type_defs(template)
222
+ GraphQL.parse("type T { #{template} }")
223
+ .definitions.first
224
+ .fields.each_with_object({}) do |node, memo|
225
+ memo[node.name] = GraphQL::Stitching::Util.flatten_ast_type_structure(node.type)
226
+ end
227
+ end
228
+
229
+ def build_argument_set(nodes, argument_defs)
230
+ if argument_defs
231
+ argument_defs.each_value do |argument_def|
232
+ if argument_def.type.non_null? && !nodes.find { _1.name == argument_def.graphql_name }
233
+ raise CompositionError, "Required argument `#{argument_def.graphql_name}` has no input."
234
+ end
235
+ end
236
+ end
237
+
238
+ nodes.map do |node|
239
+ argument_def = if argument_defs
240
+ arg = argument_defs[node.name]
241
+ raise CompositionError, "Input `#{node.name}` is not a valid argument." unless arg
242
+ arg
243
+ end
244
+
245
+ build_argument(node, argument_def: argument_def)
246
+ end
247
+ end
248
+
249
+ def build_argument(node, argument_def: nil, type_struct: nil)
250
+ value = if node.value.is_a?(GraphQL::Language::Nodes::InputObject)
251
+ build_object_value(node.value, argument_def ? argument_def.type.unwrap : nil)
252
+ elsif node.value.is_a?(GraphQL::Language::Nodes::Enum)
253
+ EnumArgumentValue.new(node.value.name)
254
+ elsif node.value.is_a?(String) && node.value.start_with?("$.")
255
+ KeyArgumentValue.new(node.value.sub(/^\$\./, "").split("."))
256
+ else
257
+ LiteralArgumentValue.new(node.value)
258
+ end
259
+
260
+ Argument.new(
261
+ name: node.name,
262
+ value: value,
263
+ # doesn't support nested lists...?
264
+ list: argument_def ? argument_def.type.list? : (type_struct&.first&.list? || false),
265
+ type_name: argument_def ? argument_def.type.unwrap.graphql_name : type_struct&.last&.name,
266
+ )
267
+ end
268
+
269
+ def build_object_value(node, object_def)
270
+ if object_def
271
+ if !object_def.kind.input_object? && !object_def.kind.scalar?
272
+ raise CompositionError, "Objects can only be built into input object and scalar positions."
273
+ elsif object_def.kind.scalar? && GraphQL::Schema::BUILT_IN_TYPES[object_def.graphql_name]
274
+ raise CompositionError, "Objects can only be built into custom scalar types."
275
+ elsif object_def.kind.scalar?
276
+ object_def = nil
277
+ end
278
+ end
279
+
280
+ ObjectArgumentValue.new(build_argument_set(node.arguments, object_def&.arguments))
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Resolver
5
+ EXPORT_PREFIX = "_export_"
6
+ TYPE_NAME = "__typename"
7
+
8
+ class FieldNode
9
+ # GraphQL Ruby changed the argument assigning Field.alias from
10
+ # a generic `alias` hash key to a structured `field_alias` kwarg
11
+ # in https://github.com/rmosolgo/graphql-ruby/pull/4718.
12
+ # This adapts to the library implementation present.
13
+ GRAPHQL_RUBY_FIELD_ALIAS_KWARG = !GraphQL::Language::Nodes::Field.new(field_alias: "a").alias.nil?
14
+
15
+ class << self
16
+ def build(field_name:, field_alias: nil, selections: GraphQL::Stitching::EMPTY_ARRAY)
17
+ if GRAPHQL_RUBY_FIELD_ALIAS_KWARG
18
+ GraphQL::Language::Nodes::Field.new(
19
+ field_alias: field_alias,
20
+ name: field_name,
21
+ selections: selections,
22
+ )
23
+ else
24
+ GraphQL::Language::Nodes::Field.new(
25
+ alias: field_alias,
26
+ name: field_name,
27
+ selections: selections,
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class KeyFieldSet < Array
35
+ def initialize(fields)
36
+ super(fields.sort_by(&:name))
37
+ @to_definition = nil
38
+ @export_nodes = nil
39
+ end
40
+
41
+ def ==(other)
42
+ to_definition == other.to_definition
43
+ end
44
+
45
+ def default_argument_name
46
+ length == 1 ? first.name : nil
47
+ end
48
+
49
+ def to_definition
50
+ @to_definition ||= map(&:to_definition).join(" ").freeze
51
+ end
52
+
53
+ alias_method :to_s, :to_definition
54
+
55
+ def export_nodes
56
+ @export_nodes ||= map(&:export_node)
57
+ end
58
+ end
59
+
60
+ EMPTY_FIELD_SET = KeyFieldSet.new(GraphQL::Stitching::EMPTY_ARRAY)
61
+ TYPENAME_EXPORT_NODE = FieldNode.build(
62
+ field_alias: "#{EXPORT_PREFIX}#{TYPE_NAME}",
63
+ field_name: TYPE_NAME,
64
+ )
65
+
66
+ class Key < KeyFieldSet
67
+ attr_reader :locations
68
+
69
+ def initialize(fields, locations: GraphQL::Stitching::EMPTY_ARRAY)
70
+ super(fields)
71
+ @locations = locations
72
+ to_definition
73
+ export_nodes
74
+ freeze
75
+ end
76
+
77
+ def export_nodes
78
+ @export_nodes ||= begin
79
+ nodes = map(&:export_node)
80
+ nodes << TYPENAME_EXPORT_NODE
81
+ nodes
82
+ end
83
+ end
84
+ end
85
+
86
+ class KeyField
87
+ # name of the key, may be a field alias
88
+ attr_reader :name
89
+
90
+ # inner key selections
91
+ attr_reader :inner
92
+
93
+ # optional information about location and typing, used during composition
94
+ attr_accessor :type_name
95
+ attr_accessor :list
96
+ alias_method :list?, :list
97
+
98
+ def initialize(name, root: false, inner: EMPTY_FIELD_SET)
99
+ @name = name
100
+ @inner = inner
101
+ @root = root
102
+ end
103
+
104
+ def to_definition
105
+ @inner.empty? ? @name : "#{@name} { #{@inner.to_definition} }"
106
+ end
107
+
108
+ def export_node
109
+ FieldNode.build(
110
+ field_alias: @root ? "#{EXPORT_PREFIX}#{@name}" : nil,
111
+ field_name: @name,
112
+ selections: @inner.export_nodes,
113
+ )
114
+ end
115
+ end
116
+
117
+ module KeysParser
118
+ def export_key(name)
119
+ "#{EXPORT_PREFIX}#{name}"
120
+ end
121
+
122
+ def export_key?(name)
123
+ return false unless name
124
+
125
+ name.start_with?(EXPORT_PREFIX)
126
+ end
127
+
128
+ def parse_key(template, locations = GraphQL::Stitching::EMPTY_ARRAY)
129
+ Key.new(parse_field_set(template), locations: locations)
130
+ end
131
+
132
+ def parse_key_with_types(template, subgraph_types_by_location)
133
+ field_set = parse_field_set(template)
134
+ locations = subgraph_types_by_location.filter_map do |location, subgraph_type|
135
+ location if field_set_matches_type?(field_set, subgraph_type)
136
+ end
137
+
138
+ if locations.none?
139
+ message = "Key `#{field_set.to_definition}` does not exist in any location."
140
+ message += " Composite key selections may not be distributed." if field_set.length > 1
141
+ raise CompositionError, message
142
+ end
143
+
144
+ assign_field_set_info!(field_set, subgraph_types_by_location[locations.first])
145
+ Key.new(field_set, locations: locations)
146
+ end
147
+
148
+ private
149
+
150
+ def parse_field_set(template)
151
+ template = template.strip
152
+ template = template[1..-2] if template.start_with?("{") && template.end_with?("}")
153
+
154
+ ast = GraphQL.parse("{ #{template} }").definitions.first.selections
155
+ build_field_set(ast, root: true)
156
+ end
157
+
158
+ def build_field_set(selections, root: false)
159
+ return EMPTY_FIELD_SET if selections.empty?
160
+
161
+ fields = selections.map do |node|
162
+ raise CompositionError, "Key selections must be fields." unless node.is_a?(GraphQL::Language::Nodes::Field)
163
+ raise CompositionError, "Key fields may not specify aliases." unless node.alias.nil?
164
+
165
+ KeyField.new(node.name, inner: build_field_set(node.selections), root: root)
166
+ end
167
+
168
+ KeyFieldSet.new(fields)
169
+ end
170
+
171
+ def field_set_matches_type?(field_set, subgraph_type)
172
+ subgraph_type = subgraph_type.unwrap
173
+ field_set.all? do |field|
174
+ # fixme: union doesn't have fields, but may support these selections...
175
+ next true if subgraph_type.kind.union?
176
+ field_matches_type?(field, subgraph_type.get_field(field.name)&.type&.unwrap)
177
+ end
178
+ end
179
+
180
+ def field_matches_type?(field, subgraph_type)
181
+ return false if subgraph_type.nil?
182
+
183
+ if field.inner.empty? && subgraph_type.kind.composite?
184
+ raise CompositionError, "Composite key fields must contain nested selections."
185
+ end
186
+
187
+ field.inner.empty? || field_set_matches_type?(field.inner, subgraph_type)
188
+ end
189
+
190
+ def assign_field_set_info!(field_set, subgraph_type)
191
+ subgraph_type = subgraph_type.unwrap
192
+ field_set.each do |field|
193
+ # fixme: union doesn't have fields, but may support these selections...
194
+ next if subgraph_type.kind.union?
195
+ assign_field_info!(field, subgraph_type.get_field(field.name).type)
196
+ end
197
+ end
198
+
199
+ def assign_field_info!(field, subgraph_type)
200
+ field.list = subgraph_type.list?
201
+ field.type_name = subgraph_type.unwrap.graphql_name
202
+ assign_field_set_info!(field.inner, subgraph_type)
203
+ end
204
+ end
205
+ end
206
+ end
@@ -1,47 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "./resolver/arguments"
4
+ require_relative "./resolver/keys"
5
+
3
6
  module GraphQL
4
7
  module Stitching
5
8
  # Defines a root resolver query that provides direct access to an entity type.
6
- Resolver = Struct.new(
9
+ class Resolver
10
+ extend ArgumentsParser
11
+ extend KeysParser
12
+
7
13
  # location name providing the resolver query.
8
- :location,
14
+ attr_reader :location
9
15
 
10
16
  # name of merged type fulfilled through this resolver.
11
- :type_name,
17
+ attr_reader :type_name
18
+
19
+ # name of the root field to query.
20
+ attr_reader :field
12
21
 
13
22
  # a key field to select from prior locations, sent as resolver argument.
14
- :key,
23
+ attr_reader :key
15
24
 
16
- # name of the root field to query.
17
- :field,
25
+ # parsed resolver Argument structures.
26
+ attr_reader :arguments
18
27
 
19
- # specifies when the resolver is a list query.
20
- :list,
28
+ def initialize(
29
+ location:,
30
+ type_name: nil,
31
+ list: false,
32
+ field: nil,
33
+ key: nil,
34
+ arguments: nil
35
+ )
36
+ @location = location
37
+ @type_name = type_name
38
+ @list = list
39
+ @field = field
40
+ @key = key
41
+ @arguments = arguments
42
+ end
21
43
 
22
- # name of the root field argument used to send the key.
23
- :arg,
44
+ # specifies when the resolver is a list query.
45
+ def list?
46
+ @list
47
+ end
24
48
 
25
- # type name of the root field argument used to send the key.
26
- :arg_type_name,
49
+ def version
50
+ @version ||= Digest::SHA2.hexdigest(as_json.to_json)
51
+ end
27
52
 
28
- # specifies that keys should be sent as JSON representations with __typename and key.
29
- :representations,
30
- keyword_init: true
31
- ) do
32
- alias_method :list?, :list
33
- alias_method :representations?, :representations
53
+ def ==(other)
54
+ self.class == other.class && self.as_json == other.as_json
55
+ end
34
56
 
35
57
  def as_json
36
58
  {
37
59
  location: location,
38
60
  type_name: type_name,
39
- key: key,
61
+ list: list?,
40
62
  field: field,
41
- list: list,
42
- arg: arg,
43
- arg_type_name: arg_type_name,
44
- representations: representations,
63
+ key: key.to_definition,
64
+ arguments: arguments.map(&:to_definition).join(", "),
65
+ argument_types: arguments.map(&:to_type_definition).join(", "),
45
66
  }.tap(&:compact!)
46
67
  end
47
68
  end
@@ -23,8 +23,8 @@ module GraphQL
23
23
  def resolve_object_scope(raw_object, parent_type, selections, typename = nil)
24
24
  return nil if raw_object.nil?
25
25
 
26
- typename ||= raw_object[ExportSelection.typename_node.alias]
27
- raw_object.reject! { |key, _v| ExportSelection.key?(key) }
26
+ typename ||= raw_object[Resolver::TYPENAME_EXPORT_NODE.alias]
27
+ raw_object.reject! { |key, _v| Resolver.export_key?(key) }
28
28
 
29
29
  selections.each do |node|
30
30
  case node
@@ -64,7 +64,7 @@ module GraphQL
64
64
  return nil if result.nil?
65
65
 
66
66
  else
67
- raise "Unexpected node of type #{node.class.name} in selection set."
67
+ raise StitchingError, "Unexpected node of type #{node.class.name} in selection set."
68
68
  end
69
69
  end
70
70
 
@@ -40,7 +40,7 @@ module GraphQL
40
40
  end
41
41
 
42
42
  if filtered_selections.none?
43
- filtered_selections << ExportSelection.typename_node
43
+ filtered_selections << Resolver::TYPENAME_EXPORT_NODE
44
44
  end
45
45
 
46
46
  if changed
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Supergraph
5
+ class KeyDirective < GraphQL::Schema::Directive
6
+ graphql_name "key"
7
+ locations OBJECT, INTERFACE, UNION
8
+ argument :key, String, required: true
9
+ argument :location, String, required: true
10
+ repeatable true
11
+ end
12
+ end
13
+ end
@@ -5,14 +5,13 @@ module GraphQL::Stitching
5
5
  class ResolverDirective < GraphQL::Schema::Directive
6
6
  graphql_name "resolver"
7
7
  locations OBJECT, INTERFACE, UNION
8
- argument :type_name, String, required: false
9
8
  argument :location, String, required: true
9
+ argument :list, Boolean, required: false
10
10
  argument :key, String, required: true
11
11
  argument :field, String, required: true
12
- argument :arg, String, required: true
13
- argument :arg_type_name, String, required: true
14
- argument :list, Boolean, required: false
15
- argument :representations, Boolean, required: false
12
+ argument :arguments, String, required: true
13
+ argument :argument_types, String, required: true
14
+ argument :type_name, String, required: false
16
15
  repeatable true
17
16
  end
18
17
  end