graph_weaver 0.7.3 → 0.7.5
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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +5 -2
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +176 -22
- data/docs/getting_started.md +174 -14
- data/docs/i18n.md +4 -4
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +161 -35
- data/docs/testing.md +24 -3
- data/docs/upgrading.md +51 -5
- data/examples/github/generated/star_mutation.rb +24 -2
- data/examples/github/generated/stargazers_query.rb +61 -5
- data/examples/github/generated/starred_query.rb +33 -3
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +35 -16
- data/lib/graph_weaver/codegen/enum_type.rb +149 -19
- data/lib/graph_weaver/codegen/nodes.rb +72 -37
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
- data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
- data/lib/graph_weaver/codegen.rb +259 -106
- data/lib/graph_weaver/coerce.rb +25 -6
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +23 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +31 -10
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/unused.rb +32 -7
- data/lib/graph_weaver/internal/values.rb +12 -4
- data/lib/graph_weaver/internal.rb +84 -0
- data/lib/graph_weaver/logging.rb +26 -29
- data/lib/graph_weaver/query_module.rb +20 -5
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +0 -1
- data/lib/graph_weaver/schema_loader.rb +7 -8
- data/lib/graph_weaver/tasks.rb +60 -5
- data/lib/graph_weaver/testing/fake_client.rb +4 -10
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +101 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +80 -74
- metadata +3 -2
data/lib/graph_weaver/coerce.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "date" # Date/DateTime, named below
|
|
5
|
+
require "time" # Time.iso8601
|
|
5
6
|
|
|
6
7
|
require_relative "errors"
|
|
7
8
|
require_relative "internal/refusal"
|
|
@@ -38,19 +39,24 @@ module GraphWeaver
|
|
|
38
39
|
# app translates for a user. It defaults to the GraphQL scalar the rule
|
|
39
40
|
# is named for; generated code passes the schema's own name, so a
|
|
40
41
|
# `register_scalar("BigInt", Integer)` field refuses as a BigInt.
|
|
42
|
+
#
|
|
43
|
+
# A real number converts to the number the scalar names, and whether it
|
|
44
|
+
# is whole is asked of the value — so a BigDecimal off a decimal column
|
|
45
|
+
# and a Rational are ordinary arguments.
|
|
41
46
|
def integer(value, scalar = "Int")
|
|
42
47
|
case value
|
|
43
48
|
when Integer then value
|
|
44
|
-
when
|
|
49
|
+
when Numeric then whole(real(value, scalar), scalar)
|
|
45
50
|
when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, scalar)
|
|
46
51
|
else refuse(value, scalar)
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
54
|
|
|
55
|
+
# Float's own precision is what asking for a Float means, so a BigDecimal
|
|
56
|
+
# past it loses digits here rather than being refused.
|
|
50
57
|
def float(value, scalar = "Float")
|
|
51
58
|
case value
|
|
52
|
-
when
|
|
53
|
-
when Integer then finite(value.to_f, scalar)
|
|
59
|
+
when Numeric then finite(real(value, scalar).to_f, scalar)
|
|
54
60
|
when String then NUMBER.match?(value.strip) ? finite(Float(value.strip), scalar) : unparseable(value, scalar)
|
|
55
61
|
else refuse(value, scalar)
|
|
56
62
|
end
|
|
@@ -70,12 +76,16 @@ module GraphWeaver
|
|
|
70
76
|
end
|
|
71
77
|
end
|
|
72
78
|
|
|
79
|
+
# A timestamp string is ISO 8601, read by Time.iso8601 — the coercer
|
|
80
|
+
# graphql-ruby's own ISO8601DateTime reads input with, and the only
|
|
81
|
+
# shape its coerce_result writes. Time.parse also reads "Jan 15 2024
|
|
82
|
+
# 10:20", which no server sends and no generated struct should take.
|
|
73
83
|
def time(value, scalar = "Time")
|
|
74
84
|
case value
|
|
75
85
|
when Time then value
|
|
76
86
|
when DateTime then value.to_time # the same instant in another class
|
|
77
87
|
when Date then cross(value, scalar, TIME_HINT)
|
|
78
|
-
when String then parsing(scalar, value) { Time.
|
|
88
|
+
when String then parsing(scalar, value) { Time.iso8601(value) }
|
|
79
89
|
else time_like?(value) ? value.to_time : refuse(value, scalar)
|
|
80
90
|
end
|
|
81
91
|
end
|
|
@@ -194,8 +204,17 @@ module GraphWeaver
|
|
|
194
204
|
raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a finite number", scalar)
|
|
195
205
|
end
|
|
196
206
|
|
|
207
|
+
# Complex is the one Numeric that is not a number a scalar can name, and
|
|
208
|
+
# #real? is the predicate for it — no class list to keep up to date.
|
|
209
|
+
def real(value, scalar)
|
|
210
|
+
return value if value.real?
|
|
211
|
+
|
|
212
|
+
refuse(value, scalar)
|
|
213
|
+
end
|
|
214
|
+
|
|
197
215
|
def whole(value, scalar = "Int")
|
|
198
|
-
# Integer(2.5) is 2 — a silent loss where refusing costs nothing
|
|
216
|
+
# Integer(2.5) is 2 — a silent loss where refusing costs nothing. `% 1`
|
|
217
|
+
# is exact for a BigDecimal and a Rational; asking Float would not be.
|
|
199
218
|
return value.to_i if value.finite? && (value % 1).zero?
|
|
200
219
|
|
|
201
220
|
raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a whole number", scalar)
|
|
@@ -236,7 +255,7 @@ module GraphWeaver
|
|
|
236
255
|
|
|
237
256
|
# the article by the initial, so a registered Ruby class ("an Integer")
|
|
238
257
|
# reads as well as the scalars ("an Int", "an ID", "a Date")
|
|
239
|
-
def expected(scalar) = "expected #{
|
|
258
|
+
def expected(scalar) = "expected #{Internal::Util.article(scalar)} #{scalar}"
|
|
240
259
|
end
|
|
241
260
|
end
|
|
242
261
|
end
|
|
@@ -341,15 +341,10 @@ module GraphWeaver
|
|
|
341
341
|
*@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
|
|
342
342
|
end
|
|
343
343
|
|
|
344
|
-
# how many coordinates the report names before it says "and N more"
|
|
345
|
-
SAMPLE = 5
|
|
346
|
-
private_constant :SAMPLE
|
|
347
|
-
|
|
348
344
|
def evidence(coordinates)
|
|
349
345
|
return "the supergraph attributes nothing to it alone" if coordinates.empty?
|
|
350
|
-
return coordinates.join(", ") if coordinates.size <= SAMPLE
|
|
351
346
|
|
|
352
|
-
|
|
347
|
+
Internal::Util.sample(coordinates)
|
|
353
348
|
end
|
|
354
349
|
|
|
355
350
|
def faked_section
|
data/lib/graph_weaver/graph.rb
CHANGED
|
@@ -54,12 +54,15 @@ module GraphWeaver
|
|
|
54
54
|
# block runs once, at the declaration (GraphBuilder.build reads the
|
|
55
55
|
# registry there), and every read after replays the module it made.
|
|
56
56
|
def replay(registry, registration)
|
|
57
|
-
call, args, kwargs, block = registration
|
|
57
|
+
call, args, kwargs, block, aliases = registration
|
|
58
58
|
entry = registry.public_send(call, *args, **kwargs, &block)
|
|
59
|
+
# the block's alias_field lines ran with it, so replay them too
|
|
60
|
+
entry[:aliases].merge!(aliases) if aliases
|
|
59
61
|
return unless block && call == :extend_type
|
|
60
62
|
|
|
61
63
|
registration[1] = args + [entry[:mixins].last]
|
|
62
64
|
registration[3] = nil
|
|
65
|
+
registration[4] = entry[:aliases].dup
|
|
63
66
|
end
|
|
64
67
|
private :replay
|
|
65
68
|
|
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -68,20 +68,38 @@ module GraphWeaver
|
|
|
68
68
|
# you generated) rather than a bad value, and T::Enum's own KeyError says
|
|
69
69
|
# neither that nor which values exist. Raised bare so the enclosing
|
|
70
70
|
# Hints.field brands it with the field.
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
# aliases (register_enum alias:) is wire spelling => the value it reads as.
|
|
72
|
+
# fallback is the Other member, when the registration asked for one.
|
|
73
|
+
def self.enum(type, value, aliases = nil, fallback: nil)
|
|
74
|
+
value = aliases.fetch(value, value) if aliases
|
|
75
|
+
member = type.try_deserialize(value)
|
|
76
|
+
return member if member
|
|
77
|
+
return absorbed(type, value, fallback) if fallback
|
|
78
|
+
|
|
79
|
+
drifted!(type, value, type.values.map(&:serialize), "register_enum fallback: true")
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
# the same, for an enum mapped onto an app-owned T::Enum, where the wire
|
|
76
83
|
# table rather than the type knows the accepted values
|
|
77
84
|
def self.mapped_enum(type, table, value)
|
|
78
|
-
table.fetch(value) { drifted!(type, value, table.keys) }
|
|
85
|
+
table.fetch(value) { drifted!(type, value, table.keys, "register_enum fallback:") }
|
|
79
86
|
end
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
# A T::Enum member is a singleton, so Other can't carry the value it
|
|
89
|
+
# swallowed — this line is the only record that anything drifted.
|
|
90
|
+
def self.absorbed(type, value, fallback)
|
|
91
|
+
GraphWeaver::Internal::Log.log(:debug) do
|
|
92
|
+
# the member's bare constant name — a T::Enum member inspects as #<Type::Name>
|
|
93
|
+
"#{type} absorbed #{GraphWeaver::Internal::Redact.shown(value)} into #{fallback.inspect[/::(\w+)>\z/, 1]}"
|
|
94
|
+
end
|
|
95
|
+
fallback
|
|
96
|
+
end
|
|
97
|
+
private_class_method :absorbed
|
|
98
|
+
|
|
99
|
+
def self.drifted!(type, value, values, suggestion)
|
|
82
100
|
raise KeyError, "#{GraphWeaver::Internal::Redact.shown(value)} is not a #{type} — expected one of: " \
|
|
83
101
|
"#{values.sort.join(", ")}; a value the server added since you generated " \
|
|
84
|
-
"needs a regenerate, or
|
|
102
|
+
"needs a regenerate, or #{suggestion} to absorb them"
|
|
85
103
|
end
|
|
86
104
|
private_class_method :drifted!
|
|
87
105
|
|
|
@@ -72,14 +72,12 @@ class GraphWeaver::InProcess
|
|
|
72
72
|
"#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
|
|
76
76
|
# a copy per query: graphql-ruby writes a resolver's `context[...] =`
|
|
77
77
|
# into the hash it is handed, and one client serves every request
|
|
78
78
|
@schema.execute(query, variables:, operation_name:,
|
|
79
79
|
context: GraphWeaver::Internal::Util.context!(@context).dup)
|
|
80
80
|
end
|
|
81
|
-
|
|
82
|
-
result
|
|
83
81
|
rescue GraphWeaver::Error
|
|
84
82
|
raise
|
|
85
83
|
rescue => e
|
|
@@ -21,19 +21,25 @@ module GraphWeaver
|
|
|
21
21
|
|
|
22
22
|
# serializer/coercer are code-as-data from the generated file; nil
|
|
23
23
|
# means identity (the wire value passes through untouched). coordinate
|
|
24
|
-
# is the schema's name for the slot ("PetFilter.species")
|
|
25
|
-
#
|
|
26
|
-
|
|
24
|
+
# is the schema's name for the slot ("PetFilter.species") and type its
|
|
25
|
+
# spelling of what goes there ("[Float!]!"), so a refusal can say where
|
|
26
|
+
# it happened, and in whose vocabulary, without reflecting at runtime.
|
|
27
|
+
Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
|
|
27
28
|
|
|
28
29
|
# An enum reaching the library as input — an execute kwarg or an input
|
|
29
30
|
# field — as the member or its wire value. Generated code calls these
|
|
30
31
|
# rather than T::Enum.deserialize / the wire table directly: both raise a
|
|
31
32
|
# bare KeyError naming an anonymous module and none of the values they
|
|
32
33
|
# would have taken.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
# fallback is the generated Other member (register_enum fallback: true).
|
|
35
|
+
# It is the one member input refuses: nothing on the wire means it, so a
|
|
36
|
+
# variable carrying it would send a value the server never declared.
|
|
37
|
+
def self.enum(type, value, aliases = nil, fallback: nil)
|
|
38
|
+
member = value.is_a?(type) ? value : type.try_deserialize(aliases ? aliases.fetch(value, value) : value)
|
|
39
|
+
return member if member && !member.equal?(fallback)
|
|
40
|
+
|
|
41
|
+
accepted = type.values.map(&:serialize) - [fallback&.serialize].compact
|
|
42
|
+
member ? unsendable_enum!(member, accepted) : invalid_enum!(type, value, accepted)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# A list element's index, prepended when something inside it refused —
|
|
@@ -107,6 +113,18 @@ module GraphWeaver
|
|
|
107
113
|
end
|
|
108
114
|
private_class_method :invalid_enum!
|
|
109
115
|
|
|
116
|
+
# The fallback member is a landing pad for drift, not a value — so it is
|
|
117
|
+
# refused by name rather than listed among the ones you could have meant.
|
|
118
|
+
def self.unsendable_enum!(member, accepted)
|
|
119
|
+
# a T::Enum member inspects as #<Type::Name>
|
|
120
|
+
raise GraphWeaver::Internal::Refusal.brand(
|
|
121
|
+
KeyError.new("#{member.inspect[2..-2]} absorbs values the server added, so " \
|
|
122
|
+
"there is nothing to send for it — expected one of: #{accepted.sort.join(", ")}"),
|
|
123
|
+
:not_a_member, members: accepted.sort,
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
private_class_method :unsendable_enum!
|
|
127
|
+
|
|
110
128
|
def self.included(base)
|
|
111
129
|
base.extend(ClassMethods)
|
|
112
130
|
end
|
|
@@ -260,11 +278,14 @@ module GraphWeaver
|
|
|
260
278
|
# out blaming the list that held the struct, in sorbet's words.
|
|
261
279
|
next if T::Utils.coerce(info[:type_object]).recursively_valid?(value)
|
|
262
280
|
|
|
263
|
-
|
|
281
|
+
# the SCHEMA's spelling, from the FIELDS row — #details[:type] is
|
|
282
|
+
# what an app translates for a user, and "T::Array[Float]" is the
|
|
283
|
+
# library's vocabulary leaking into theirs
|
|
284
|
+
field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
|
|
285
|
+
type = field&.type || T::Utils.coerce(info[:type]).to_s
|
|
264
286
|
return GraphWeaver::InputError.new(
|
|
265
287
|
"#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
266
|
-
kind: :type_mismatch, path: [prop.to_s],
|
|
267
|
-
coordinate: T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }&.coordinate,
|
|
288
|
+
kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
|
|
268
289
|
value: GraphWeaver::Internal::Redact.value(prop, value),
|
|
269
290
|
details: { type: }, struct: self,
|
|
270
291
|
)
|
|
@@ -34,9 +34,6 @@ module GraphWeaver
|
|
|
34
34
|
# goes through the same check *and refuses at construction* — a swapped
|
|
35
35
|
# pair fails there rather than as a mystery three fetches later.
|
|
36
36
|
module Subgraphs
|
|
37
|
-
# how many coordinates a message names before it says "and N more"
|
|
38
|
-
SAMPLE = 5
|
|
39
|
-
|
|
40
37
|
# answer this subgraph with fabricated data rather than refusing
|
|
41
38
|
FAKE = :fake
|
|
42
39
|
|
|
@@ -117,15 +114,9 @@ module GraphWeaver
|
|
|
117
114
|
return schema if gaps.empty?
|
|
118
115
|
|
|
119
116
|
raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
|
|
120
|
-
"#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
|
|
117
|
+
"#{schema.name || schema.inspect}, which doesn't define #{Util.sample(gaps)} — the supergraph " \
|
|
121
118
|
"says #{name} resolves them. Did two entries get swapped?"
|
|
122
119
|
end
|
|
123
|
-
|
|
124
|
-
def sample(list)
|
|
125
|
-
return list.join(", ") if list.size <= SAMPLE
|
|
126
|
-
|
|
127
|
-
"#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
|
|
128
|
-
end
|
|
129
120
|
end
|
|
130
121
|
end
|
|
131
122
|
end
|
|
@@ -47,6 +47,11 @@ module GraphWeaver
|
|
|
47
47
|
# .json.erb's sibling JS — is a blind spot, and the footer says so.
|
|
48
48
|
# .rake and .builder are Ruby too.
|
|
49
49
|
EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
|
|
50
|
+
# Ruby that carries no extension to recognise it by. In a non-Rails
|
|
51
|
+
# project the entry points live here, so skipping them skipped the
|
|
52
|
+
# files that read the query.
|
|
53
|
+
SCRIPT_DIRS = Set["bin", "exe"].freeze
|
|
54
|
+
RUBY_SHEBANG = /\A#!.*\bruby\b/
|
|
50
55
|
# Directories that hold no app source. "generated" covers both a graph's
|
|
51
56
|
# own output under the convention and a spec/generated fixture dir; a
|
|
52
57
|
# graph that writes somewhere else is pruned by #outputs.
|
|
@@ -60,8 +65,9 @@ module GraphWeaver
|
|
|
60
65
|
# difference between a lint and a number somebody trusts.
|
|
61
66
|
FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
|
|
62
67
|
"as\nused the moment anything says it. It can't see a prop reached by public_send, or a " \
|
|
63
|
-
"read\nin a file type it doesn't sweep
|
|
64
|
-
"
|
|
68
|
+
"read\nin a file type it doesn't sweep — #{EXTENSIONS.join(", ")},\nplus Ruby with no " \
|
|
69
|
+
"extension (any name under bin/ or exe/, a ruby shebang elsewhere). On\na real app half to " \
|
|
70
|
+
"two thirds of genuinely unread selections go unreported; silence is\nthe safe direction."
|
|
65
71
|
|
|
66
72
|
# query: the .graphql that selected it. struct/prop: where it landed.
|
|
67
73
|
# wire: how the query spells that prop, when it differs.
|
|
@@ -244,19 +250,22 @@ module GraphWeaver
|
|
|
244
250
|
end
|
|
245
251
|
|
|
246
252
|
def files
|
|
247
|
-
@files ||= @roots
|
|
253
|
+
@files ||= @roots
|
|
254
|
+
.flat_map { |root| collect(root, [], SCRIPT_DIRS.include?(File.basename(root))) }
|
|
255
|
+
.uniq.sort
|
|
248
256
|
end
|
|
249
257
|
|
|
250
258
|
# Pruned as it walks rather than globbed and filtered: node_modules is
|
|
251
|
-
# the directory you most want never to descend into.
|
|
252
|
-
|
|
259
|
+
# the directory you most want never to descend into. scripts says we are
|
|
260
|
+
# inside bin/ or exe/, which the walk knows and a path doesn't.
|
|
261
|
+
def collect(dir, found, scripts)
|
|
253
262
|
Dir.children(dir).sort.each do |entry|
|
|
254
263
|
path = File.join(dir, entry)
|
|
255
264
|
# lstat, so a symlinked directory can't loop the walk
|
|
256
265
|
stat = File.lstat(path)
|
|
257
266
|
if stat.directory?
|
|
258
|
-
collect(path, found) unless skip_dir?(entry, path)
|
|
259
|
-
elsif stat.file? &&
|
|
267
|
+
collect(path, found, scripts || SCRIPT_DIRS.include?(entry)) unless skip_dir?(entry, path)
|
|
268
|
+
elsif stat.file? && ruby?(path, entry, scripts)
|
|
260
269
|
found << path
|
|
261
270
|
end
|
|
262
271
|
end
|
|
@@ -265,6 +274,22 @@ module GraphWeaver
|
|
|
265
274
|
found
|
|
266
275
|
end
|
|
267
276
|
|
|
277
|
+
# An extension names most of it. A file with none is Ruby if it sits
|
|
278
|
+
# under bin/ or exe/ — that is what those directories are for — or if
|
|
279
|
+
# its first line says so.
|
|
280
|
+
def ruby?(path, entry, scripts)
|
|
281
|
+
return true if EXTENSIONS.include?(File.extname(entry))
|
|
282
|
+
return false unless File.extname(entry).empty?
|
|
283
|
+
|
|
284
|
+
scripts || shebang?(path)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def shebang?(path)
|
|
288
|
+
File.open(path) { |file| file.gets(chomp: true) }&.match?(RUBY_SHEBANG) || false
|
|
289
|
+
rescue SystemCallError, ArgumentError
|
|
290
|
+
false
|
|
291
|
+
end
|
|
292
|
+
|
|
268
293
|
def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
|
|
269
294
|
|
|
270
295
|
# The generated directories a name check can't catch: a graph that sets
|
|
@@ -51,8 +51,16 @@ class GraphWeaver::Internal::Values
|
|
|
51
51
|
UNREGISTERED = "T.untyped"
|
|
52
52
|
|
|
53
53
|
# What JSON can hold. Anything else a pin offers is a Ruby object the
|
|
54
|
-
# registration has to serialize before it can stand in for a response
|
|
55
|
-
|
|
54
|
+
# registration has to serialize before it can stand in for a response —
|
|
55
|
+
# Integer and Float, not Numeric, because a BigDecimal is a Numeric that
|
|
56
|
+
# JSON has no spelling for, and taking one as written sent 12.5 where the
|
|
57
|
+
# registration says the server writes "12.5".
|
|
58
|
+
WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
|
|
59
|
+
|
|
60
|
+
# Whether a value is already one of those. At a leaf it means the registry's
|
|
61
|
+
# serializer has nothing to do; at a composite position (FakeClient) it means
|
|
62
|
+
# the pin stands as written.
|
|
63
|
+
def self.wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
|
|
56
64
|
|
|
57
65
|
# The fallback, for a scalar nobody registered: its prop is T.untyped, so
|
|
58
66
|
# anything holds and a plausible shape beats a placeholder.
|
|
@@ -143,10 +151,10 @@ class GraphWeaver::Internal::Values
|
|
|
143
151
|
# as written. Shared with the object-pin door, so both read a pin the same
|
|
144
152
|
# way.
|
|
145
153
|
def wire(type_name, value, coordinate = nil)
|
|
146
|
-
return value if
|
|
154
|
+
return value if self.class.wire?(value)
|
|
147
155
|
|
|
148
156
|
serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
|
|
149
|
-
return serialized if
|
|
157
|
+
return serialized if self.class.wire?(serialized)
|
|
150
158
|
|
|
151
159
|
article = GraphWeaver::Internal::Util.article(value.class.to_s)
|
|
152
160
|
raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
|
|
@@ -18,6 +18,11 @@ module GraphWeaver
|
|
|
18
18
|
# lexical scope, so a private constant would be unreachable from exactly
|
|
19
19
|
# the files that need it. The name and the surface lock carry the rule.
|
|
20
20
|
module Internal
|
|
21
|
+
# The wire value the member register_enum fallback: true adds to a
|
|
22
|
+
# generated enum serializes to. The GraphQL spec reserves a leading `__`,
|
|
23
|
+
# so no schema can declare a value that collides with it.
|
|
24
|
+
ENUM_FALLBACK_WIRE = "__other__"
|
|
25
|
+
|
|
21
26
|
# Odds and ends several files share. Each is here because more than one
|
|
22
27
|
# caller needs it, not because it belongs together with the others.
|
|
23
28
|
module Util
|
|
@@ -50,6 +55,18 @@ module GraphWeaver
|
|
|
50
55
|
# "a" or "an" for a word an error message is about to name.
|
|
51
56
|
def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
|
|
52
57
|
|
|
58
|
+
# how many entries a message names before it says "and N more"
|
|
59
|
+
SAMPLE = 5
|
|
60
|
+
private_constant :SAMPLE
|
|
61
|
+
|
|
62
|
+
# A list a message names inline, held to a readable length — a wall
|
|
63
|
+
# of schema coordinates says less than the first few and a count.
|
|
64
|
+
def sample(list)
|
|
65
|
+
return list.join(", ") if list.size <= SAMPLE
|
|
66
|
+
|
|
67
|
+
"#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
|
|
68
|
+
end
|
|
69
|
+
|
|
53
70
|
# The module a .graphql file generates, and the basename of the file
|
|
54
71
|
# it generates into: the camelized file name plus the operation's own
|
|
55
72
|
# word. Every run of non-alphanumerics in the name is a word boundary,
|
|
@@ -299,6 +316,73 @@ module GraphWeaver
|
|
|
299
316
|
end
|
|
300
317
|
end
|
|
301
318
|
|
|
319
|
+
# One query, checked against one schema. Both doors onto it —
|
|
320
|
+
# GraphWeaver.check_queries (every file on disk) and Client#check_query
|
|
321
|
+
# (a string) — report the same hashes and brand subgraphs the same way,
|
|
322
|
+
# because the implementation lives here rather than once each.
|
|
323
|
+
module QueryCheck
|
|
324
|
+
class << self
|
|
325
|
+
# A query's schema-validation errors as JSON-ready hashes, with the
|
|
326
|
+
# source position graphql-ruby reports. Unparseable counts as an error
|
|
327
|
+
# too — it doesn't validate either, and inline_fragments (which parses
|
|
328
|
+
# first) has already branded it with its position.
|
|
329
|
+
def errors(schema, source, shared, table = nil)
|
|
330
|
+
# path omitted: check_queries keys its report by file, so branding the
|
|
331
|
+
# message with it too would just print the path twice
|
|
332
|
+
schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
|
|
333
|
+
detail = error.to_h
|
|
334
|
+
location = detail["locations"]&.first || {}
|
|
335
|
+
subgraphs = table ? attribute(table, detail["extensions"]) : []
|
|
336
|
+
entry = {
|
|
337
|
+
"message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
|
|
338
|
+
"line" => location["line"],
|
|
339
|
+
"column" => location["column"],
|
|
340
|
+
}
|
|
341
|
+
subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
|
|
342
|
+
end
|
|
343
|
+
rescue GraphWeaver::QueryValidationError => e
|
|
344
|
+
# an unparseable query: codegen folds the position (and the file) into
|
|
345
|
+
# the message, and this report keeps them separate — same splitter the
|
|
346
|
+
# rendered error uses, so the two can't drift apart
|
|
347
|
+
e.errors.map do |detail|
|
|
348
|
+
_path, _position, message = GraphWeaver::QueryValidationError.split(detail)
|
|
349
|
+
detail.transform_keys(&:to_s).merge("message" => message)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The routing table behind a dump path, when the dump is a composed
|
|
354
|
+
# supergraph: it says who resolves what, so a validation error can name
|
|
355
|
+
# the subgraph whose code to look at. nil for anything else — a plain
|
|
356
|
+
# schema, a url client, a live class are all unaffected.
|
|
357
|
+
def routing_table_for(path)
|
|
358
|
+
path = path.to_path if path.respond_to?(:to_path)
|
|
359
|
+
return unless path&.end_with?(".graphql", ".gql")
|
|
360
|
+
|
|
361
|
+
sdl = File.read(path)
|
|
362
|
+
SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private
|
|
366
|
+
|
|
367
|
+
# Which subgraphs a validation error is about, on a federated schema:
|
|
368
|
+
# "Field 'weight' doesn't exist on type 'Product'" is much less useful
|
|
369
|
+
# than the same line plus "(products)" — whose code to look at, whose
|
|
370
|
+
# team to talk to. graphql-ruby reports the coordinate structurally, so
|
|
371
|
+
# this is a lookup rather than message parsing. Both halves of the
|
|
372
|
+
# coordinate are required: an argument error reports typeName "Field"
|
|
373
|
+
# (the AST node kind, not a type), and looking that up would attribute
|
|
374
|
+
# confidently and wrongly.
|
|
375
|
+
def attribute(table, extensions)
|
|
376
|
+
return [] unless extensions
|
|
377
|
+
|
|
378
|
+
type_name, field_name = extensions.values_at("typeName", "fieldName")
|
|
379
|
+
return [] unless type_name && field_name
|
|
380
|
+
|
|
381
|
+
table.responsible(type_name, field_name)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
302
386
|
# What makes two GraphQL requests the same request — and how one reads
|
|
303
387
|
# when an error has to quote it. A cassette matches on this, so the
|
|
304
388
|
# rules belong somewhere both the cassette and the error that reports a
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -101,7 +101,11 @@ module GraphWeaver
|
|
|
101
101
|
# about the key the value arrived under, so it reads a filtered key one
|
|
102
102
|
# level in as safe; this scrubs at every depth, like #value. The key is
|
|
103
103
|
# optional because a coercer refusing a value hasn't been told one.
|
|
104
|
-
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw)
|
|
104
|
+
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(spell(value(key, raw)))
|
|
105
|
+
|
|
106
|
+
# How a value reads inside a sentence. inspect, except that
|
|
107
|
+
# BigDecimal#inspect is scientific ("0.25e1" for the 2.5 a caller wrote).
|
|
108
|
+
def spell(value) = defined?(BigDecimal) && value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
|
|
105
109
|
|
|
106
110
|
# A short server-chosen string the library republishes inside its own
|
|
107
111
|
# text — the APM's :code, the [CODE] in the one line info writes, a
|
|
@@ -215,36 +219,13 @@ module GraphWeaver
|
|
|
215
219
|
end
|
|
216
220
|
|
|
217
221
|
# What a Retry has already spent, read by the attempt it is about
|
|
218
|
-
# to make.
|
|
219
|
-
|
|
220
|
-
# that never reaches instrument can't leave a stale one behind.
|
|
221
|
-
def with_retries(count)
|
|
222
|
-
return yield unless GraphWeaver.instrumenter
|
|
223
|
-
|
|
224
|
-
previous = Thread.current[RETRIES]
|
|
225
|
-
Thread.current[RETRIES] = count
|
|
226
|
-
begin
|
|
227
|
-
yield
|
|
228
|
-
ensure
|
|
229
|
-
Thread.current[RETRIES] = previous
|
|
230
|
-
end
|
|
231
|
-
end
|
|
222
|
+
# to make.
|
|
223
|
+
def with_retries(count, &block) = during(RETRIES, count, &block)
|
|
232
224
|
|
|
233
225
|
# The graph a generated module is dispatching, read by the request it
|
|
234
|
-
# is about to make
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
def with_graph(name)
|
|
238
|
-
return yield unless GraphWeaver.instrumenter
|
|
239
|
-
|
|
240
|
-
previous = Thread.current[GRAPH]
|
|
241
|
-
Thread.current[GRAPH] = name
|
|
242
|
-
begin
|
|
243
|
-
yield
|
|
244
|
-
ensure
|
|
245
|
-
Thread.current[GRAPH] = previous
|
|
246
|
-
end
|
|
247
|
-
end
|
|
226
|
+
# is about to make — and instrument clears it for the duration of the
|
|
227
|
+
# request it labels, so exactly one request wears the label.
|
|
228
|
+
def with_graph(name, &block) = during(GRAPH, name, &block)
|
|
248
229
|
|
|
249
230
|
# The variables as one JSON line for a log: filtered, and unable to
|
|
250
231
|
# raise. A value with no JSON form (NaN, binary) is the caller's bug
|
|
@@ -278,6 +259,22 @@ module GraphWeaver
|
|
|
278
259
|
|
|
279
260
|
private
|
|
280
261
|
|
|
262
|
+
# One fiber-local, set for the length of one call. A dynamic extent
|
|
263
|
+
# rather than a global: the value is only visible while the call it
|
|
264
|
+
# describes is on the stack, so a client that never reaches instrument
|
|
265
|
+
# can't leave a stale one behind.
|
|
266
|
+
def during(key, value)
|
|
267
|
+
return yield unless GraphWeaver.instrumenter
|
|
268
|
+
|
|
269
|
+
previous = Thread.current[key]
|
|
270
|
+
Thread.current[key] = value
|
|
271
|
+
begin
|
|
272
|
+
yield
|
|
273
|
+
ensure
|
|
274
|
+
Thread.current[key] = previous
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
281
278
|
# The GraphQL errors a response carries, whatever answered it — a
|
|
282
279
|
# Hash from a transport, a graphql-ruby Result in-process, a fake.
|
|
283
280
|
# Never raises: an instrumenter that decides which exception a
|
|
@@ -7,8 +7,6 @@ require_relative "internal"
|
|
|
7
7
|
require_relative "internal/test_clients"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
|
-
# Called by generated code — not semver'd for direct use.
|
|
11
|
-
#
|
|
12
10
|
# Runtime for generated query modules: the client plumbing, which is the
|
|
13
11
|
# one part of a generated module that carries no per-query type
|
|
14
12
|
# information — every module's copy was identical. `extend
|
|
@@ -16,6 +14,13 @@ module GraphWeaver
|
|
|
16
14
|
# stay generated, since their sigs are the query's types and those are the
|
|
17
15
|
# point.
|
|
18
16
|
#
|
|
17
|
+
# It is also the type every generated module satisfies, so code that takes
|
|
18
|
+
# any of them says `GraphWeaver::QueryModule` and reads `query_string` /
|
|
19
|
+
# `operation_name` with a sig behind each — rather than `const_get(:QUERY)`
|
|
20
|
+
# on a Module, which is what rubocop-sorbet forbids (ConstantsFromStrings,
|
|
21
|
+
# and ForbidTUnsafe for the T.unsafe that gets around it). Those readers
|
|
22
|
+
# and `client` are the supported surface; the rest is generated code's.
|
|
23
|
+
#
|
|
19
24
|
# Resolution order, per the docs: per call → a test mode's stand-in
|
|
20
25
|
# (Internal::TestClients) → the client the module's graph names →
|
|
21
26
|
# `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
|
|
@@ -32,6 +37,18 @@ module GraphWeaver
|
|
|
32
37
|
@client || default_client
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
# The operation, verbatim — what goes on the wire as `query`.
|
|
41
|
+
sig { returns(String) }
|
|
42
|
+
def query_string
|
|
43
|
+
T.unsafe(self).const_get(:QUERY)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What goes on the wire as `operationName`; nil for an anonymous operation.
|
|
47
|
+
sig { returns(T.nilable(String)) }
|
|
48
|
+
def operation_name
|
|
49
|
+
T.unsafe(self).const_get(:OPERATION_NAME)
|
|
50
|
+
end
|
|
51
|
+
|
|
35
52
|
private
|
|
36
53
|
|
|
37
54
|
# Bound by GraphWeaver.parse, which is the only caller: a parsed module
|
|
@@ -59,12 +76,10 @@ module GraphWeaver
|
|
|
59
76
|
# comes through here.)
|
|
60
77
|
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
61
78
|
|
|
62
|
-
mod = T.unsafe(self)
|
|
63
79
|
# the graph codegen baked in, never one inferred from the client — a
|
|
64
80
|
# wrong label on a request is worse than no label
|
|
65
81
|
GraphWeaver::Internal::Log.with_graph(graph_name) do
|
|
66
|
-
client_for(client).execute(
|
|
67
|
-
operation_name: mod.const_get(:OPERATION_NAME))
|
|
82
|
+
client_for(client).execute(query_string, variables:, operation_name:)
|
|
68
83
|
end
|
|
69
84
|
end
|
|
70
85
|
|
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -35,7 +35,7 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
35
35
|
KEYS = %i[watch].freeze
|
|
36
36
|
|
|
37
37
|
def method_missing(name, *args)
|
|
38
|
-
key = name
|
|
38
|
+
key = setting(name)
|
|
39
39
|
return super if KEYS.include?(key)
|
|
40
40
|
|
|
41
41
|
raise ArgumentError, refusal(key)
|
|
@@ -53,11 +53,16 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
53
53
|
alias_method :store, :[]=
|
|
54
54
|
|
|
55
55
|
def respond_to_missing?(name, _private = false)
|
|
56
|
-
KEYS.include?(name
|
|
56
|
+
KEYS.include?(setting(name))
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
+
# the setting a reader, writer or predicate is about
|
|
62
|
+
def setting(name)
|
|
63
|
+
name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
|
|
64
|
+
end
|
|
65
|
+
|
|
61
66
|
def refusal(key)
|
|
62
67
|
near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
|
|
63
68
|
fix =
|