graph_weaver 0.6.0 → 0.7.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +43 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +490 -116
  70. metadata +56 -1
@@ -1,7 +1,10 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "date" # Date/DateTime, named below
5
+
4
6
  require_relative "errors"
7
+ require_relative "internal/refusal"
5
8
 
6
9
  module GraphWeaver
7
10
  # Called by generated code — not semver'd for direct use.
@@ -24,66 +27,139 @@ module GraphWeaver
24
27
  INTEGER = /\A[+-]?\d+\z/
25
28
  NUMBER = /\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/
26
29
 
30
+ # said where a date and a timestamp are given for each other
31
+ DATE_HINT = "pass .to_date if dropping the time of day is what you meant"
32
+ TIME_HINT = "a Date has no time of day — pass the Time you mean"
33
+ private_constant :DATE_HINT, :TIME_HINT
34
+
27
35
  class << self
28
- def integer(value)
36
+ # `scalar` is the schema's name for what this rule builds, and it is what
37
+ # the refusal reports — as the message and as #details[:type], which an
38
+ # app translates for a user. It defaults to the GraphQL scalar the rule
39
+ # is named for; generated code passes the schema's own name, so a
40
+ # `register_scalar("BigInt", Integer)` field refuses as a BigInt.
41
+ def integer(value, scalar = "Int")
29
42
  case value
30
43
  when Integer then value
31
- when Float then whole(value)
32
- when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, "Int")
33
- else refuse(value, "Int")
44
+ when Float then whole(value, scalar)
45
+ when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, scalar)
46
+ else refuse(value, scalar)
47
+ end
48
+ end
49
+
50
+ def float(value, scalar = "Float")
51
+ case value
52
+ when Float then finite(value, scalar)
53
+ when Integer then finite(value.to_f, scalar)
54
+ when String then NUMBER.match?(value.strip) ? finite(Float(value.strip), scalar) : unparseable(value, scalar)
55
+ else refuse(value, scalar)
56
+ end
57
+ end
58
+
59
+ # A date stays a Date and a timestamp a Time. Converting between them
60
+ # drops the time of day or invents a midnight, so a cross-type value is
61
+ # refused rather than guessed at — by class, since a timestamp printed
62
+ # in full looks a great deal like a date.
63
+ def date(value, scalar = "Date")
64
+ case value
65
+ # DateTime is a Date to Ruby and a timestamp to everyone else
66
+ when DateTime, Time then cross(value, scalar, DATE_HINT)
67
+ when Date then value
68
+ when String then parsing(scalar, value) { Date.iso8601(value) }
69
+ else time_like?(value) ? cross(value, scalar, DATE_HINT) : refuse(value, scalar)
34
70
  end
35
71
  end
36
72
 
37
- def float(value)
73
+ def time(value, scalar = "Time")
38
74
  case value
39
- when Float then value
40
- when Integer then value.to_f
41
- when String then NUMBER.match?(value.strip) ? Float(value.strip) : unparseable(value, "Float")
42
- else refuse(value, "Float")
75
+ when Time then value
76
+ when DateTime then value.to_time # the same instant in another class
77
+ when Date then cross(value, scalar, TIME_HINT)
78
+ when String then parsing(scalar, value) { Time.parse(value) }
79
+ else time_like?(value) ? value.to_time : refuse(value, scalar)
43
80
  end
44
81
  end
45
82
 
83
+ # A timestamp on the wire: ISO 8601, carrying microseconds only when the
84
+ # value has them. A server that writes sub-second times (every JS one
85
+ # does) round-trips through Ruby unchanged, and a value that doesn't
86
+ # sends the bytes it always has.
87
+ def timestamp(value)
88
+ # DateTime spells its fraction #sec_fraction, Time (and an
89
+ # ActiveSupport::TimeWithZone) spell it #subsec
90
+ fraction = value.respond_to?(:subsec) ? value.subsec : value.sec_fraction
91
+ value.iso8601(fraction.zero? ? 0 : 6)
92
+ end
93
+
46
94
  # Ruby has no Kernel#Boolean, and every string rule ("0", "off", "no")
47
95
  # is somebody's convention — so refuse rather than pick one.
48
- def boolean(value)
96
+ def boolean(value, scalar = "Boolean")
49
97
  return value if value == true || value == false
50
98
 
51
- refuse(value, "Boolean", "there is no one right reading of it — convert at the call site")
99
+ refuse(value, scalar, "there is no one right reading of it — convert at the call site")
52
100
  end
53
101
 
54
- def string(value)
102
+ def string(value, scalar = "String")
55
103
  return value if value.is_a?(String)
56
104
 
57
- refuse(value, "String")
105
+ refuse(value, scalar)
58
106
  end
59
107
 
60
108
  # The GraphQL spec has ID serialize as a String but accept an integer
61
109
  # input, which is `execute(id: user.id)` — the everyday Rails call.
62
110
  # String gets no such licence: an Integer where a String belongs is
63
111
  # more often a bug than a spelling.
64
- def id(value)
112
+ def id(value, scalar = "ID")
65
113
  case value
66
114
  when String then value
67
115
  when Integer then value.to_s
68
- else refuse(value, "ID")
116
+ else refuse(value, scalar)
69
117
  end
70
118
  end
71
119
 
120
+ # A registration's own `cast:` — Date.iso8601, Money.parse — standing in
121
+ # for a Coerce rule the library has none of. Two things the codec can't
122
+ # do for itself: the pass-through guard (a value already of the type is
123
+ # not rebuilt), and a verdict, since a cast complains about the value
124
+ # alone — "no implicit conversion of Integer into String" names neither
125
+ # what was expected nor which half of the value was wrong.
126
+ def cast(type, value, scalar = nil)
127
+ scalar ||= type.is_a?(Module) ? type.name : type.to_s
128
+ # DateTime is a Date to Ruby and a timestamp to everyone else, so it is
129
+ # not "already a Date" however the app spelled its cast: .date's own
130
+ # refusal, applied BEFORE the cast rather than instead of it.
131
+ cross(value, scalar, DATE_HINT) if type.equal?(::Date) && value.is_a?(::DateTime)
132
+ # a type given as a type string ("T::Hash[...]") names no class to ask
133
+ return value if type.is_a?(Module) && value.is_a?(type)
134
+
135
+ parsing(scalar, value) { yield value }
136
+ end
137
+
72
138
  # Brands one variable's coercion failure with the variable and the
73
139
  # operation: a cast complains about the value alone ("invalid date"),
74
- # which locates nothing in an app that runs a hundred queries.
140
+ # which locates nothing in an app that runs a hundred queries. The
141
+ # variable is also the root of #path, so a nested refusal comes out
142
+ # holding the whole route from the kwarg down to the field.
75
143
  def variable(name, operation, value)
76
144
  yield value
77
145
  rescue GraphWeaver::InputError => e
78
146
  raise GraphWeaver::InputError.new(
79
- "#{at(name, operation)}: #{Internal::Redact.detail(name, e.message)}", field: name, struct: e.struct,
147
+ "#{at(name, operation)}: #{Internal::Redact.detail(name, e.message)}",
148
+ kind: e.kind, path: [name, *e.path], coordinate: e.coordinate,
149
+ # #value is the value AT #path: this layer owns it only when nothing
150
+ # inner named a field (so a missing one stays valueless, as it is) —
151
+ # but the variable's own filter covers everything beneath it
152
+ value: Internal::Redact.value(name, e.path.empty? ? value : e.value),
153
+ details: e.details, struct: e.struct,
80
154
  )
81
155
  rescue StandardError => e
82
156
  # a cast complains about the value without quoting it ("invalid date")
83
- shown = value.inspect
84
- got = " (got #{shown})" unless e.message.include?(shown)
157
+ quoted = shown(value)
158
+ got = " (got #{quoted})" unless e.message.include?(quoted)
85
159
  raise GraphWeaver::InputError.new(
86
- "#{at(name, operation)}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}", field: name,
160
+ "#{at(name, operation)}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}",
161
+ kind: Internal::Refusal.kind_of(e), path: [name],
162
+ value: Internal::Redact.value(name, value), details: Internal::Refusal.details_of(e),
87
163
  )
88
164
  end
89
165
 
@@ -91,23 +167,76 @@ module GraphWeaver
91
167
 
92
168
  def at(name, operation) = operation ? "$#{name} of #{operation}" : "$#{name}"
93
169
 
94
- def whole(value)
170
+ # A stdlib parser's own complaint, kept word for word ("invalid date")
171
+ # and labelled with the scalar the schema named — which the parser,
172
+ # handed only a String, has no way to know. Ruby's own convention splits
173
+ # the two: ArgumentError is wrong CONTENT, and the parser says more
174
+ # about it than we could; TypeError is a wrong CLASS, which the parser
175
+ # describes in terms of its own argument ("no implicit conversion of
176
+ # Integer into String") and we can name properly.
177
+ def parsing(scalar, value = nil)
178
+ yield
179
+ rescue ::ArgumentError => e
180
+ raise Internal::Refusal.brand(e, :unparseable, type: scalar)
181
+ rescue ::TypeError
182
+ raise mismatch(::TypeError, "#{expected(scalar)}, got #{shown(value)}", scalar)
183
+ end
184
+
185
+ # Kernel#Float("1e400") is Infinity rather than a raise, and so is
186
+ # (10**400).to_f — while JSON has no spelling for a non-finite number
187
+ # and the GraphQL spec excludes them from Float outright. Refusing here
188
+ # names the variable; the transport otherwise complains that the
189
+ # variables aren't serializable, a whole query away from the value.
190
+ def finite(value, scalar = "Float")
191
+ return value if value.finite?
192
+
193
+ # a number, but not one GraphQL's Float admits — no conversion applies
194
+ raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a finite number", scalar)
195
+ end
196
+
197
+ def whole(value, scalar = "Int")
95
198
  # Integer(2.5) is 2 — a silent loss where refusing costs nothing
96
199
  return value.to_i if value.finite? && (value % 1).zero?
97
200
 
98
- raise ArgumentError, "#{expected("Int")}, got #{value.inspect} — not a whole number"
201
+ raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a whole number", scalar)
99
202
  end
100
203
 
101
204
  def unparseable(value, scalar)
102
- raise ArgumentError, "#{expected(scalar)}, got #{value.inspect}"
205
+ raise Internal::Refusal.brand(
206
+ ArgumentError.new("#{expected(scalar)}, got #{shown(value)}"), :unparseable, type: scalar
207
+ )
208
+ end
209
+
210
+ # the refusal a caller sees is the plain Ruby error it always was —
211
+ # the kind rides along for the layer that brands it (see Refusal)
212
+ def mismatch(klass, message, scalar)
213
+ Internal::Refusal.brand(klass.new(message), :type_mismatch, type: scalar)
214
+ end
215
+
216
+ # ActiveSupport::TimeWithZone — what Time.zone.now returns — is not a
217
+ # Time but is one for every purpose, and #to_time is lossless. acts_like?
218
+ # is Rails' own duck-type check, so nothing answers it by accident.
219
+ # (The real TimeWithZone also answers is_a?(Time); this doesn't rely on
220
+ # it, so a value that stops lying keeps working.)
221
+ def time_like?(value)
222
+ value.respond_to?(:acts_like?) && value.acts_like?(:time) && value.respond_to?(:to_time)
223
+ end
224
+
225
+ def cross(value, scalar, hint)
226
+ raise mismatch(::TypeError, "#{expected(scalar)}, got a #{value.class} — #{hint}", scalar)
103
227
  end
104
228
 
105
- # ::TypeError — inside GraphWeaver, a bare TypeError is ours
106
229
  def refuse(value, scalar, hint = nil)
107
- raise ::TypeError, "#{expected(scalar)}, got #{value.inspect}#{" — #{hint}" if hint}"
230
+ raise mismatch(::TypeError, "#{expected(scalar)}, got #{shown(value)}#{" — #{hint}" if hint}", scalar)
108
231
  end
109
232
 
110
- def expected(scalar) = "expected #{%w[Int ID].include?(scalar) ? "an" : "a"} #{scalar}"
233
+ # the value a refusal quotes: scrubbed at every depth, so a filtered key
234
+ # inside an input object never reaches a sentence (see Redact)
235
+ def shown(value) = Internal::Redact.shown(value)
236
+
237
+ # the article by the initial, so a registered Ruby class ("an Integer")
238
+ # reads as well as the scalars ("an Int", "an ID", "a Date")
239
+ def expected(scalar) = "expected #{scalar.start_with?(/[AEIOU]/) ? "an" : "a"} #{scalar}"
111
240
  end
112
241
  end
113
242
  end