graphql-doctor 0.1.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +71 -0
- data/Rakefile +9 -0
- data/docs/diagnostics/GQLD101.md +7 -0
- data/docs/diagnostics/GQLD102.md +7 -0
- data/docs/diagnostics/GQLD103.md +7 -0
- data/docs/diagnostics/GQLD104.md +7 -0
- data/docs/diagnostics/GQLD201.md +7 -0
- data/docs/diagnostics/GQLD202.md +7 -0
- data/docs/diagnostics/GQLD203.md +7 -0
- data/docs/diagnostics/GQLD204.md +7 -0
- data/docs/diagnostics/GQLD205.md +7 -0
- data/docs/diagnostics/GQLD301.md +7 -0
- data/docs/diagnostics/GQLD302.md +7 -0
- data/docs/diagnostics/GQLD303.md +7 -0
- data/docs/diagnostics/GQLD305.md +7 -0
- data/docs/diagnostics/GQLD306.md +7 -0
- data/docs/diagnostics/GQLD307.md +7 -0
- data/docs/diagnostics/GQLD401.md +7 -0
- data/docs/diagnostics/README.md +29 -0
- data/exe/graphql-doctor +6 -0
- data/lib/graphql/doctor/cache.rb +47 -0
- data/lib/graphql/doctor/checks/argument_keyword_match.rb +308 -0
- data/lib/graphql/doctor/checks/base.rb +121 -0
- data/lib/graphql/doctor/checks/engine.rb +74 -0
- data/lib/graphql/doctor/checks/resolver_method_presence.rb +68 -0
- data/lib/graphql/doctor/cli.rb +227 -0
- data/lib/graphql/doctor/config.rb +168 -0
- data/lib/graphql/doctor/correlation/correlator.rb +58 -0
- data/lib/graphql/doctor/diagnostic.rb +51 -0
- data/lib/graphql/doctor/ir.rb +30 -0
- data/lib/graphql/doctor/location.rb +52 -0
- data/lib/graphql/doctor/reporters/github.rb +34 -0
- data/lib/graphql/doctor/reporters/json.rb +19 -0
- data/lib/graphql/doctor/reporters/sarif.rb +82 -0
- data/lib/graphql/doctor/reporters/text.rb +47 -0
- data/lib/graphql/doctor/runner.rb +71 -0
- data/lib/graphql/doctor/runtime/dump.rb +137 -0
- data/lib/graphql/doctor/runtime/reflector.rb +313 -0
- data/lib/graphql/doctor/runtime/schema_loader.rb +40 -0
- data/lib/graphql/doctor/source/file_visitor.rb +366 -0
- data/lib/graphql/doctor/source/index.rb +164 -0
- data/lib/graphql/doctor/source/loader.rb +146 -0
- data/lib/graphql/doctor/source/name_mangler.rb +31 -0
- data/lib/graphql/doctor/suppression.rb +39 -0
- data/lib/graphql/doctor/version.rb +7 -0
- data/lib/graphql/doctor.rb +23 -0
- metadata +105 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
module Runtime
|
|
8
|
+
class Reflector
|
|
9
|
+
OWNER_PREPARE_VERSION = Gem::Version.new("2.1.8")
|
|
10
|
+
|
|
11
|
+
def initialize(schema, source_index: nil, abstract_classes: [])
|
|
12
|
+
@schema = schema
|
|
13
|
+
@source_index = source_index
|
|
14
|
+
@abstract_classes = abstract_classes
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
{
|
|
19
|
+
"version" => 1,
|
|
20
|
+
"graphql_ruby_version" => GraphQL::VERSION,
|
|
21
|
+
"schema" => @schema.name,
|
|
22
|
+
"types" => reflected_types,
|
|
23
|
+
"orphan_members" => orphan_members
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def reflected_types
|
|
30
|
+
@reflected_types ||= @schema.types.values.filter_map do |type|
|
|
31
|
+
next unless reflectable_type?(type)
|
|
32
|
+
|
|
33
|
+
fields = (type.respond_to?(:fields) ? type.fields.values : []).filter_map do |field|
|
|
34
|
+
reflect_field(field) if owned_field?(field, type)
|
|
35
|
+
end
|
|
36
|
+
next if fields.empty? && built_in?(type)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"name" => type.graphql_name,
|
|
40
|
+
"kind" => type_kind(type),
|
|
41
|
+
"ruby_class" => type.name,
|
|
42
|
+
"interfaces" => type.respond_to?(:interfaces) ? type.interfaces.map(&:name) : [],
|
|
43
|
+
"source_location" => constant_source_location(type.name),
|
|
44
|
+
"fields" => fields
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reflect_field(field)
|
|
50
|
+
owner = field.owner
|
|
51
|
+
return if owner.name.nil?
|
|
52
|
+
|
|
53
|
+
resolver_owner = field.resolver || owner
|
|
54
|
+
resolver_method = field.resolver ? field.resolver.resolve_method : field.resolver_method
|
|
55
|
+
source_options = source_field_options(owner.name, field.graphql_name)
|
|
56
|
+
object_class = underlying_object_class(owner)
|
|
57
|
+
{
|
|
58
|
+
"graphql_name" => field.graphql_name,
|
|
59
|
+
"owner" => owner.name,
|
|
60
|
+
"method_sym" => field.method_sym.to_s,
|
|
61
|
+
"resolver_method" => resolver_method.to_s,
|
|
62
|
+
"hash_key" => json_value(configured_hash_key(member_value(field, :hash_key)) || source_options[:hash_key]),
|
|
63
|
+
"dig_keys" => json_value(member_value(field, :dig_keys) || source_options[:dig]),
|
|
64
|
+
"resolver_class" => field.resolver&.name,
|
|
65
|
+
"type" => field.type.to_type_signature,
|
|
66
|
+
"extras" => field.extras.map(&:to_s),
|
|
67
|
+
"extensions" => field.extensions.map { |extension| extension.class.name },
|
|
68
|
+
**method_metadata(resolver_owner, resolver_method, field.resolver),
|
|
69
|
+
"underlying_object_class" => object_class&.name,
|
|
70
|
+
"underlying_method_defined" => object_class&.public_method_defined?(field.method_sym),
|
|
71
|
+
"arguments" => field_arguments(field).map { |argument| reflect_argument(argument, field) }
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reflect_argument(argument, field)
|
|
76
|
+
prepare = prepare_value(argument.prepare)
|
|
77
|
+
method, dispatch = prepare_method(prepare, field)
|
|
78
|
+
{
|
|
79
|
+
"graphql_name" => argument.graphql_name,
|
|
80
|
+
"keyword" => argument.keyword.to_s,
|
|
81
|
+
"type" => argument.type.to_type_signature,
|
|
82
|
+
"non_null" => argument.type.non_null?,
|
|
83
|
+
"required" => argument.type.non_null? || required_nullable?(argument),
|
|
84
|
+
"has_default" => argument.default_value?,
|
|
85
|
+
"default_value" => argument.default_value? ? json_value(argument.default_value) : nil,
|
|
86
|
+
"loads" => constant_name(argument.loads),
|
|
87
|
+
"as" => member_value(argument, :as)&.to_s,
|
|
88
|
+
"prepare" => prepare,
|
|
89
|
+
"prepare_source_location" => method&.source_location,
|
|
90
|
+
"prepare_method_valid" => method && prepare_method_valid?(method, dispatch == "resolver" ? 2 : 1),
|
|
91
|
+
"prepare_method_dispatch" => dispatch
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prepare_method(prepare, field)
|
|
96
|
+
return [nil, nil] unless prepare
|
|
97
|
+
|
|
98
|
+
object_method = public_instance_method(field.owner, prepare)
|
|
99
|
+
return [object_method, "object"] if object_method
|
|
100
|
+
return [nil, nil] unless field.resolver && Gem::Version.new(GraphQL::VERSION) >= OWNER_PREPARE_VERSION
|
|
101
|
+
|
|
102
|
+
[field.resolver.public_method(prepare), "resolver"]
|
|
103
|
+
rescue NameError
|
|
104
|
+
[nil, nil]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def required_nullable?(argument)
|
|
108
|
+
owner = argument.owner
|
|
109
|
+
return false unless owner.respond_to?(:validators)
|
|
110
|
+
|
|
111
|
+
owner.validators.any? do |validator|
|
|
112
|
+
next false unless validator.is_a?(GraphQL::Schema::Validator::RequiredValidator)
|
|
113
|
+
|
|
114
|
+
names = Array(validator.instance_variable_get(:@one_of))
|
|
115
|
+
names.one? && GraphQL::Schema::Member::BuildType.camelize(names.first.to_s) == argument.graphql_name
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def public_instance_method(owner, name)
|
|
120
|
+
owner.public_instance_method(name)
|
|
121
|
+
rescue NameError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def prepare_method_valid?(method, positional_count)
|
|
126
|
+
parameters = method.parameters
|
|
127
|
+
required = parameters.count { |kind, _name| kind == :req }
|
|
128
|
+
optional = parameters.count { |kind, _name| kind == :opt }
|
|
129
|
+
accepts_rest = parameters.any? { |kind, _name| kind == :rest }
|
|
130
|
+
required_keywords = parameters.any? { |kind, _name| kind == :keyreq }
|
|
131
|
+
maximum_valid = accepts_rest || positional_count <= required + optional
|
|
132
|
+
!required_keywords && required <= positional_count && maximum_valid
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def callback_methods(resolver)
|
|
136
|
+
return {} unless resolver
|
|
137
|
+
|
|
138
|
+
%i[ready? authorized?].to_h { |name| [name.to_s, instance_method(resolver, name)] }.compact
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def method_metadata(owner, name, resolver_class)
|
|
142
|
+
resolver = instance_method(owner, name)
|
|
143
|
+
callbacks = callback_methods(resolver_class)
|
|
144
|
+
{
|
|
145
|
+
"resolver_source_location" => resolver&.source_location,
|
|
146
|
+
"resolver_signature" => method_signature(owner, name, resolver),
|
|
147
|
+
"callback_source_locations" => callbacks.transform_values(&:source_location),
|
|
148
|
+
"callback_signatures" => callbacks.transform_values do |method|
|
|
149
|
+
method_signature(resolver_class, method.name, method)
|
|
150
|
+
end
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def method_signature(owner, name, method)
|
|
155
|
+
return unless method
|
|
156
|
+
|
|
157
|
+
parameters = method.parameters
|
|
158
|
+
{
|
|
159
|
+
"visibility" => method_visibility(owner, name).to_s,
|
|
160
|
+
"required_keywords" => parameters.filter_map { |kind, key| key.to_s if kind == :keyreq },
|
|
161
|
+
"optional_keywords" => parameters.filter_map { |kind, key| key.to_s if kind == :key },
|
|
162
|
+
"accepts_keyword_rest" => parameters.any? { |kind, _key| kind == :keyrest },
|
|
163
|
+
"required_positionals" => parameters.count { |kind, _key| kind == :req },
|
|
164
|
+
"optional_positionals" => parameters.count { |kind, _key| kind == :opt },
|
|
165
|
+
"accepts_positional_rest" => parameters.any? { |kind, _key| kind == :rest }
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def method_visibility(owner, name)
|
|
170
|
+
return :public if owner.public_method_defined?(name)
|
|
171
|
+
return :protected if owner.protected_method_defined?(name)
|
|
172
|
+
|
|
173
|
+
:private
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def field_arguments(field)
|
|
177
|
+
resolver = field.resolver
|
|
178
|
+
if defined?(GraphQL::Schema::RelayClassicMutation) && resolver && resolver < GraphQL::Schema::RelayClassicMutation
|
|
179
|
+
return resolver.all_argument_definitions.reject { |argument| argument.keyword == :client_mutation_id }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
field.arguments.values
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def orphan_members
|
|
186
|
+
return [] unless @source_index
|
|
187
|
+
|
|
188
|
+
referenced = reflected_types.flat_map do |type|
|
|
189
|
+
type["fields"].filter_map { |field| field["resolver_class"] }
|
|
190
|
+
end
|
|
191
|
+
candidates = @source_index.types.filter_map do |definition|
|
|
192
|
+
next if definition.confidence == :dynamic
|
|
193
|
+
next unless resolver_namespace?(definition.owner)
|
|
194
|
+
next unless resolver_class?(definition.owner)
|
|
195
|
+
|
|
196
|
+
definition.owner
|
|
197
|
+
end
|
|
198
|
+
candidates = candidates.uniq
|
|
199
|
+
candidates.reject! { |name| abstract_with_subclass?(name, candidates) }
|
|
200
|
+
candidates - referenced.uniq
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def resolver_class?(name)
|
|
204
|
+
constant = constantize(name)
|
|
205
|
+
constant < GraphQL::Schema::Resolver || constant < GraphQL::Schema::Mutation
|
|
206
|
+
rescue NameError
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def resolver_namespace?(name)
|
|
211
|
+
name.match?(/(?:\A|::)(?:Mutations|Resolvers)::/)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def abstract_with_subclass?(name, candidates)
|
|
215
|
+
return false unless @abstract_classes.include?(name)
|
|
216
|
+
|
|
217
|
+
parent = constantize(name)
|
|
218
|
+
candidates.any? { |candidate| candidate != name && constantize(candidate) < parent }
|
|
219
|
+
rescue NameError
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def constantize(name)
|
|
224
|
+
name.split("::").reduce(Object) { |namespace, part| namespace.const_get(part, false) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def instance_method(owner, method_name)
|
|
228
|
+
owner.instance_method(method_name)
|
|
229
|
+
rescue NameError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def configured_hash_key(value)
|
|
234
|
+
value if value.is_a?(String) || value.is_a?(Symbol)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def json_value(value)
|
|
238
|
+
case value
|
|
239
|
+
when Hash then value.to_h { |key, item| [key.to_s, json_value(item)] }
|
|
240
|
+
when Array then value.map { |item| json_value(item) }
|
|
241
|
+
when Float then value.finite? ? value : value.to_s
|
|
242
|
+
when String, Integer, TrueClass, FalseClass, NilClass then value
|
|
243
|
+
else value.to_s
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def source_field_options(owner, graphql_name)
|
|
248
|
+
return {} unless @source_index
|
|
249
|
+
|
|
250
|
+
field = @source_index.fields.find do |definition|
|
|
251
|
+
definition.owner == owner && definition.graphql_name == graphql_name
|
|
252
|
+
end
|
|
253
|
+
field ? field.options : {}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def member_value(member, name)
|
|
257
|
+
return member.public_send(name) if member.respond_to?(name)
|
|
258
|
+
|
|
259
|
+
member.instance_variable_get("@#{name}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def constant_source_location(name)
|
|
263
|
+
return unless name
|
|
264
|
+
|
|
265
|
+
Object.const_source_location(name)
|
|
266
|
+
rescue NameError
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def constant_name(value)
|
|
271
|
+
value.respond_to?(:name) ? value.name : value&.to_s
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def underlying_object_class(owner)
|
|
275
|
+
return unless owner.is_a?(Class) && owner < GraphQL::Schema::Object && owner.respond_to?(:object_class)
|
|
276
|
+
|
|
277
|
+
object_class = owner.object_class
|
|
278
|
+
object_class if object_class.is_a?(Class) && object_class.name && !(object_class <= Hash)
|
|
279
|
+
rescue StandardError
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def prepare_value(value)
|
|
284
|
+
value.is_a?(Symbol) ? value.to_s : (value if value.is_a?(String))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def built_in?(type)
|
|
288
|
+
type.name&.start_with?("GraphQL::")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def type_kind(type)
|
|
292
|
+
type.kind.name
|
|
293
|
+
rescue NoMethodError
|
|
294
|
+
type.class.name.split("::").last.upcase
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def supported_type?(type)
|
|
298
|
+
%w[OBJECT INTERFACE INPUT_OBJECT UNION ENUM].include?(type_kind(type))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def reflectable_type?(type)
|
|
302
|
+
supported_type?(type) && !type.graphql_name.start_with?("__")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def owned_field?(field, type)
|
|
306
|
+
return true if field.owner == type
|
|
307
|
+
|
|
308
|
+
field.owner < GraphQL::Schema::Mutation && !built_in?(field.owner)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
module Runtime
|
|
6
|
+
class SchemaLoader
|
|
7
|
+
def self.load(config, root: Dir.pwd)
|
|
8
|
+
boot_environment(config["require"], root)
|
|
9
|
+
|
|
10
|
+
name = config["schema"]
|
|
11
|
+
raise Error, "Configuration key `schema` is required" unless name && !name.strip.empty?
|
|
12
|
+
|
|
13
|
+
schema = resolve_schema(name)
|
|
14
|
+
raise Error, "Configured schema `#{name}` is not a GraphQL::Schema subclass" unless graphql_schema?(schema)
|
|
15
|
+
|
|
16
|
+
schema
|
|
17
|
+
rescue StandardError, LoadError, SyntaxError => e
|
|
18
|
+
raise Error, "GQLD101: Failed to boot schema: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.boot_environment(require_path, root)
|
|
22
|
+
require File.expand_path(require_path, root) if require_path && !require_path.empty?
|
|
23
|
+
Rails.application.eager_load! if defined?(Rails.application) && Rails.application.respond_to?(:eager_load!)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.resolve_schema(name)
|
|
27
|
+
name.split("::").reject(&:empty?).reduce(Object) do |namespace, constant|
|
|
28
|
+
namespace.const_get(constant, false)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.graphql_schema?(schema)
|
|
33
|
+
defined?(GraphQL::Schema) && schema.is_a?(Class) && schema < GraphQL::Schema
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method :boot_environment, :resolve_schema, :graphql_schema?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|