graphql-stitching 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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