graph_weaver 0.5.1 → 0.6.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +105 -81
  11. data/docs/logging.md +34 -4
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +191 -151
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +210 -11
  16. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  17. data/lib/graph_weaver/client.rb +46 -13
  18. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  19. data/lib/graph_weaver/codegen/emit.rb +96 -39
  20. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  21. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  22. data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
  23. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  24. data/lib/graph_weaver/codegen.rb +284 -84
  25. data/lib/graph_weaver/coerce.rb +113 -0
  26. data/lib/graph_weaver/errors.rb +30 -7
  27. data/lib/graph_weaver/federation.rb +6 -5
  28. data/lib/graph_weaver/hints.rb +76 -2
  29. data/lib/graph_weaver/in_process.rb +11 -8
  30. data/lib/graph_weaver/inflect.rb +2 -0
  31. data/lib/graph_weaver/input_struct.rb +115 -12
  32. data/lib/graph_weaver/internal/overrides.rb +101 -0
  33. data/lib/graph_weaver/internal/planner.rb +868 -0
  34. data/lib/graph_weaver/internal/schemas.rb +50 -0
  35. data/lib/graph_weaver/internal/selection.rb +127 -0
  36. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  37. data/lib/graph_weaver/internal/values.rb +181 -0
  38. data/lib/graph_weaver/internal.rb +206 -0
  39. data/lib/graph_weaver/logging.rb +108 -20
  40. data/lib/graph_weaver/parsing.rb +5 -4
  41. data/lib/graph_weaver/query_module.rb +2 -0
  42. data/lib/graph_weaver/railtie.rb +113 -14
  43. data/lib/graph_weaver/representation.rb +30 -2
  44. data/lib/graph_weaver/response.rb +15 -0
  45. data/lib/graph_weaver/retry.rb +54 -22
  46. data/lib/graph_weaver/rspec.rb +50 -11
  47. data/lib/graph_weaver/schema_diff.rb +293 -0
  48. data/lib/graph_weaver/schema_loader.rb +96 -29
  49. data/lib/graph_weaver/tasks.rb +78 -29
  50. data/lib/graph_weaver/testing/cassette.rb +49 -65
  51. data/lib/graph_weaver/testing/coverage.rb +5 -4
  52. data/lib/graph_weaver/testing/failure.rb +10 -6
  53. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  54. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  55. data/lib/graph_weaver/testing/router.rb +94 -808
  56. data/lib/graph_weaver/testing.rb +35 -84
  57. data/lib/graph_weaver/transport/faraday.rb +1 -1
  58. data/lib/graph_weaver/transport/http.rb +29 -12
  59. data/lib/graph_weaver/transport.rb +11 -34
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +188 -110
  62. metadata +10 -5
  63. data/lib/graph_weaver/schemas.rb +0 -48
  64. data/lib/graph_weaver/selection.rb +0 -120
  65. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -19,27 +19,32 @@ end
19
19
  # GraphWeaver::Testing.configure do |config|
20
20
  # config.schema = MySchema # overrides the derived schema
21
21
  # config.router = { subgraphs: { "reviews" => :fake } } # or supergraph:,
22
- # # when it isn't the dump
22
+ # # when it isn't the dump, or
23
+ # # fake: for how those fabricate
23
24
  # config.context = { current_user: } # baseline GraphQL context
24
25
  # config.default_mode = :fake # untagged examples (graph_weaver/rspec)
25
26
  # config.seed = 42 # reproducible fakes
26
- # config.mode = :faker # or :literal; nil = auto
27
27
  # config.overrides = { "Person.name" => "Daniel" }
28
28
  # config.list_size = 2..4
29
- # config.null_chance = 0.1 # nullable fields go nil sometimes
30
29
  # config.cassette_dir = "spec/cassettes"
31
30
  # end
32
31
  #
33
- # mode picks how values are fabricated:
34
- # :faker — semantic, field-name matched (requires the faker gem)
35
- # :literal plain type-derived values ("name-1", seeded numbers)
36
- # nil — auto: :faker when the gem is loaded, else :literal
32
+ # Anything whose honest answer differs per example belongs on the fake, not
33
+ # here: graphql_fake(null_chance: 1.0) for the empty-state example,
34
+ # graphql_fake(values: :literal) for the one that reads better without
35
+ # faker's prose, and overrides:/list_size: to say what one example's data is.
37
36
  #
38
37
  # rspec users: require "graph_weaver/rspec" instead — it hooks the suite
39
38
  # (seed from rspec, a client per example from its `graphql:` tag).
40
39
  module GraphWeaver
41
40
  module Testing
42
- MODES = [:faker, :literal].freeze
41
+ # How a fake writes its values, named by the `values:` a fake takes —
42
+ # graphql_fake(values: :literal), FakeClient.new(values:):
43
+ #
44
+ # :faker semantic, matched on the field name (needs the faker gem)
45
+ # :literal plain type-derived values ("name-1", seeded numbers)
46
+ # nil auto: :faker when the gem is loaded, else :literal
47
+ VALUE_STYLES = %i[faker literal].freeze
43
48
 
44
49
  # What an example can run against, named by the rspec tag that selects
45
50
  # it — `it "…", graphql: :in_process` (see graph_weaver/rspec):
@@ -50,19 +55,17 @@ module GraphWeaver
50
55
  CLIENT_MODES = %i[fake in_process router].freeze
51
56
 
52
57
  class Config
53
- attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
58
+ attr_accessor :overrides, :seed, :list_size, :cassette_dir, :context,
54
59
  :record, :anonymize
55
60
  # #schema is written plainly and read with a fallback (below), the way
56
- # #mode, #router and #default_mode are read plainly and written with a check
61
+ # #router and #default_mode are read plainly and written with a check
57
62
  attr_writer :schema
58
- attr_reader :mode, :router, :default_mode
63
+ attr_reader :router, :default_mode
59
64
 
60
65
  def initialize
61
66
  @overrides = {}
62
67
  @seed = nil
63
68
  @list_size = 1..3
64
- @null_chance = 0.0
65
- @mode = nil # auto
66
69
  @schema = nil
67
70
  @located = nil # the committed dump, once located
68
71
  # not under spec/fixtures: `fixtures :all` globs that path for
@@ -100,14 +103,6 @@ module GraphWeaver
100
103
  # that never asks for one.
101
104
  def explicit_schema = @schema
102
105
 
103
- def mode=(mode)
104
- unless mode.nil? || MODES.include?(mode)
105
- raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
106
- end
107
-
108
- @mode = mode
109
- end
110
-
111
106
  def default_mode=(mode)
112
107
  unless mode.nil? || CLIENT_MODES.include?(mode)
113
108
  raise ArgumentError,
@@ -118,13 +113,14 @@ module GraphWeaver
118
113
  @default_mode = mode
119
114
  end
120
115
 
121
- # Router arguments — both keys optional, and each answers a different
116
+ # Router arguments — every key optional, and each answers a different
122
117
  # question. `supergraph:` is for one derivation can't find; without it
123
118
  # the conventional dump is used, when that dump is itself a supergraph.
124
119
  # `subgraphs:` is for what derivation can't settle, or for `"reviews"
125
120
  # => :fake`, which fabricates a subgraph this process doesn't serve —
126
121
  # the commonest reason to configure a router at all, and no reason to
127
- # have to restate where the supergraph is.
122
+ # have to restate where the supergraph is. `fake:` says how those
123
+ # fabricate; graphql_router(fake: …) says it for one example.
128
124
  def router=(arguments)
129
125
  unless arguments.nil? || arguments.is_a?(Hash)
130
126
  raise ArgumentError, "router: must be the arguments to build one, e.g. " \
@@ -137,7 +133,7 @@ module GraphWeaver
137
133
  raise ArgumentError, "router: context: is set as config.context — the baseline every " \
138
134
  ":in_process and :router example starts from"
139
135
  end
140
- unknown = (arguments&.keys || []) - %i[supergraph subgraphs]
136
+ unknown = (arguments&.keys || []) - %i[supergraph subgraphs fake]
141
137
  raise ArgumentError, "router: doesn't take #{unknown.join(", ")}" if unknown.any?
142
138
 
143
139
  @router = arguments
@@ -148,14 +144,18 @@ module GraphWeaver
148
144
  # #context is settable, so an example that runs as someone else sets
149
145
  # that rather than rebuilding — the rspec hook resets it each time.
150
146
  def built_router
151
- @built_router ||= Router.new(supergraph: supergraph!, subgraphs: @router && @router[:subgraphs])
147
+ @built_router ||= Router.new(
148
+ supergraph: supergraph!,
149
+ subgraphs: @router && @router[:subgraphs],
150
+ fake: (@router && @router[:fake]) || {},
151
+ )
152
152
  end
153
153
 
154
154
  # The composed supergraph :router plans against — named, or the
155
155
  # conventional dump when that's what it is. A client can't supply
156
156
  # one: its schema is the API schema a router serves, with the
157
157
  # @join__* routing table stripped out.
158
- def supergraph!
158
+ private def supergraph!
159
159
  return @router[:supergraph] if @router&.key?(:supergraph)
160
160
 
161
161
  path = GraphWeaver::SchemaLoader.locate_path
@@ -175,7 +175,7 @@ module GraphWeaver
175
175
  # explicit_schema, not schema: the latter falls back to the committed
176
176
  # dump, which loads as an anonymous GraphQL::Schema subclass — runnable
177
177
  # by every test that matters, and holding not one resolver.
178
- runnable(explicit_schema) || GraphWeaver.live_schema ||
178
+ runnable(explicit_schema) || GraphWeaver::Internal::Util.live_schema ||
179
179
  raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
180
180
  "GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
181
181
  "borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
@@ -219,81 +219,32 @@ module GraphWeaver
219
219
  yield config
220
220
  # a typo'd override key pins nothing and the test still passes, so
221
221
  # catch it here — while the block that set it is still on the stack
222
- validate_overrides!(config.explicit_schema, config.overrides) if config.explicit_schema
222
+ Internal::Overrides.validate!(config.explicit_schema, config.overrides) if config.explicit_schema
223
223
  config
224
224
  end
225
225
 
226
- # Override keys name schema coordinates: "Type.field", or a bare field
227
- # name matching that field on any type. Anything else is a typo that
228
- # would silently fabricate random data instead of pinning a value.
229
- def validate_overrides!(schema, overrides)
230
- overrides.each_key { |key| validate_override_key!(schema, key.to_s) }
231
- end
232
-
233
226
  # back to defaults — between tests, or to undo an experiment
234
227
  def reset!
235
228
  @config = nil
236
229
  end
237
230
 
238
231
  # resolve a cassette name ("github") against cassette_dir; paths
239
- # with separators or extensions pass through
232
+ # with separators or extensions name the file themselves
240
233
  def cassette_path(name)
241
- return name if name.include?("/") || name.end_with?(".yml", ".yaml")
234
+ return Internal::Util.resolve(name) if name.include?("/") || name.end_with?(".yml", ".yaml")
242
235
 
243
236
  File.join(cassette_dir, "#{name}.yml")
244
237
  end
245
238
 
246
- # The configured directory, against Rails.root when there is one — a rake
247
- # task runs from wherever it runs from; the cassettes don't move.
248
- # const_get rather than a bare Rails: sorbet can't resolve a constant the
249
- # gem doesn't depend on.
250
- def cassette_dir
251
- dir = config.cassette_dir
252
- root = (Object.const_get(:Rails).root if Object.const_defined?(:Rails))
253
- root ? File.join(root.to_s, dir) : dir
254
- rescue NoMethodError
255
- dir # something else named Rails
256
- end
257
-
258
- private
259
-
260
- def validate_override_key!(schema, key)
261
- type_name, field_name = key.split(".", 2)
262
- # introspection fields (__typename) are real but absent from #fields
263
- return if (field_name || type_name).start_with?("__")
264
-
265
- if field_name.nil?
266
- known = field_names(schema)
267
- return if known.include?(type_name)
268
-
269
- bad_override!(key, "matches no field in this schema", known, type_name)
270
- end
271
-
272
- type = schema.get_type(type_name)
273
- unless type.respond_to?(:fields)
274
- bad_override!(key, "names no object type in this schema", schema.types.keys, type_name)
275
- end
276
- return if type.fields.key?(field_name)
277
-
278
- bad_override!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
279
- end
280
-
281
- def bad_override!(key, problem, dictionary, term)
282
- suggestion = GraphWeaver.did_you_mean(dictionary, term)
283
- hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
284
- raise GraphWeaver::Error, "override key #{key.inspect} #{problem}#{hint}"
285
- end
286
-
287
- # Every output field name in the schema — walked only when a bare key
288
- # asks for it.
289
- def field_names(schema)
290
- schema.types.each_value.flat_map { |type| type.respond_to?(:fields) ? type.fields.keys : [] }.uniq
291
- end
239
+ # The configured directory as a real path — a rake task or an rspec run
240
+ # starts from wherever it starts from; the cassettes don't move.
241
+ def cassette_dir = Internal::Util.resolve(config.cassette_dir)
292
242
  end
293
243
  end
294
244
  end
295
245
 
296
- require_relative "testing/values"
246
+ require_relative "internal/overrides"
247
+ require_relative "internal/values"
297
248
  require_relative "testing/fake_client"
298
249
  require_relative "testing/fake_subgraph"
299
250
  require_relative "testing/failure"
@@ -61,7 +61,7 @@ module GraphWeaver
61
61
  # which adapter got picked decides socket reuse — Faraday's
62
62
  # default net_http one opens a connection per request. Naming it
63
63
  # is the cheapest way to make that discoverable.
64
- GraphWeaver.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
64
+ GraphWeaver::Internal::Log.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
65
65
  end
66
66
 
67
67
  private
@@ -39,7 +39,18 @@ module GraphWeaver
39
39
  raise ArgumentError, "pool_size: must be >= 1" unless pool_size >= 1
40
40
 
41
41
  @url = url
42
- @uri = URI(url)
42
+ # a scheme-less url parses as a *path* with no host, and the failure
43
+ # then lands as far away as Net::HTTP.start(nil, nil)
44
+ uri = begin
45
+ URI(url)
46
+ rescue URI::InvalidURIError
47
+ nil
48
+ end
49
+ unless uri && %w[http https].include?(uri.scheme)
50
+ raise ArgumentError, "expected an http(s) url, got #{url.inspect}"
51
+ end
52
+
53
+ @uri = uri
43
54
  @headers = headers
44
55
  @open_timeout = open_timeout
45
56
  @read_timeout = read_timeout
@@ -63,7 +74,7 @@ module GraphWeaver
63
74
  # live connections, LIFO — a warm socket beats opening a cold one,
64
75
  # so a single-threaded caller keeps reusing the same one
65
76
  @idle = []
66
- @idle_lock = Mutex.new
77
+ @lock = Mutex.new # guards @idle and @saturated
67
78
  end
68
79
 
69
80
  private
@@ -84,12 +95,14 @@ module GraphWeaver
84
95
  # is the concurrency ceiling.
85
96
  def with_connection
86
97
  acquire_permit
87
- http = nil
88
-
98
+ # nothing between acquiring the permit and the ensure that returns it:
99
+ # an async interrupt (Rack::Timeout, a fiber cancel) landing in that
100
+ # gap would leak a permit, shrinking the pool for the process's life
89
101
  begin
90
- http = @idle_lock.synchronize { @idle.pop } || connect
102
+ http = nil
103
+ http = @lock.synchronize { @idle.pop } || connect
91
104
  result = yield http
92
- @idle_lock.synchronize { @idle.push(http) }
105
+ @lock.synchronize { @idle.push(http) }
93
106
  result
94
107
  rescue Exception
95
108
  # socket state is unknown — drop it, leaving the slot empty so
@@ -107,14 +120,19 @@ module GraphWeaver
107
120
  # when idle past keep_alive_timeout, so a server-closed keep-alive
108
121
  # socket doesn't produce spurious failures.
109
122
  def connect
110
- GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
111
- Net::HTTP.start(
123
+ GraphWeaver::Internal::Log.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
124
+ http = Net::HTTP.start(
112
125
  @uri.hostname, @uri.port,
113
126
  use_ssl: @uri.scheme == "https",
114
127
  open_timeout: @open_timeout, read_timeout: @read_timeout,
115
128
  keep_alive_timeout: @keep_alive_timeout,
116
129
  **@ssl,
117
130
  )
131
+ # net/http otherwise returns the bytes that did arrive, so a
132
+ # connection dying mid-body reads as a server answering with garbage —
133
+ # a permanent ServerError — instead of the retriable EOFError it is
134
+ http.ignore_eof = false
135
+ http
118
136
  end
119
137
 
120
138
  # Take a permit, saying so when none is free. A queued request is
@@ -129,9 +147,8 @@ module GraphWeaver
129
147
  @permits.pop
130
148
  waited = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
131
149
 
132
- first = !@saturated
133
- @saturated = true
134
- GraphWeaver.log(first ? :warn : :debug) do
150
+ first = @lock.synchronize { !@saturated && (@saturated = true) }
151
+ GraphWeaver::Internal::Log.log(first ? :warn : :debug) do
135
152
  "connection pool saturated: waited #{waited}ms for 1 of #{@pool_size} connections to " \
136
153
  "#{@uri.hostname} — raise pool_size: to this process's concurrency"
137
154
  end
@@ -140,7 +157,7 @@ module GraphWeaver
140
157
  def disconnect(http)
141
158
  return unless http
142
159
 
143
- GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
160
+ GraphWeaver::Internal::Log.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
144
161
  http.finish if http.started?
145
162
  rescue IOError
146
163
  # already closed
@@ -5,6 +5,7 @@ require "json"
5
5
  require "sorbet-runtime"
6
6
 
7
7
  require_relative "errors"
8
+ require_relative "internal"
8
9
  require_relative "version"
9
10
 
10
11
  # Base class for the bundled network transports — Transport::HTTP
@@ -48,10 +49,10 @@ class GraphWeaver::Transport
48
49
  # slow-query reports on. Generated modules pass their OPERATION_NAME;
49
50
  # a raw query string falls back to the name in the document itself.
50
51
  def execute(query, variables: {}, operation_name: nil)
51
- operation_name ||= GraphWeaver::Transport.operation_name(query)
52
+ operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
52
53
  payload = { url:, operation: operation_name }
53
54
 
54
- GraphWeaver.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
55
+ GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
55
56
  perform(query, variables, operation_name, payload)
56
57
  end
57
58
  end
@@ -61,11 +62,13 @@ class GraphWeaver::Transport
61
62
  private def perform(query, variables, operation_name, payload)
62
63
  # tag pairs this request's log lines (threads interleave), and names
63
64
  # the operation so the log says WHICH query, not just the url
64
- tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
65
+ tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
65
66
 
66
- # full query + variables at debug only — they can carry PII
67
- GraphWeaver.log(:debug) do
68
- "POST #{url} #{tag} variables=#{JSON.generate(variables)}\n#{GraphWeaver::Transport.truncate_for_log(query)}"
67
+ # full query + variables at debug only — they can carry PII, and the
68
+ # sensitive keys are scrubbed even there (GraphWeaver.filter_parameters)
69
+ GraphWeaver::Internal::Log.log(:debug) do
70
+ filtered = JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))
71
+ "POST #{url} #{tag} variables=#{filtered}\n#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
69
72
  end
70
73
 
71
74
  # camelCase because it's the graphql-over-http request field, not a
@@ -84,7 +87,7 @@ class GraphWeaver::Transport
84
87
  # headers is optional: a third-party subclass returning the
85
88
  # documented [status, body] pair simply has none
86
89
  status, body, headers = begin
87
- GraphWeaver.log_timed(:debug, "POST #{url} #{tag} completed") do
90
+ GraphWeaver::Internal::Log.log_timed(:debug, "POST #{url} #{tag} completed") do
88
91
  post(encoded)
89
92
  end
90
93
  rescue *GraphWeaver.transport_errors.to_a => e
@@ -93,7 +96,7 @@ class GraphWeaver::Transport
93
96
  end
94
97
 
95
98
  payload[:status] = status
96
- GraphWeaver.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
99
+ GraphWeaver::Internal::Log.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
97
100
 
98
101
  parsed = parse_body(body)
99
102
 
@@ -139,32 +142,6 @@ class GraphWeaver::Transport
139
142
  end
140
143
  alias to_s inspect
141
144
 
142
- # The name of the document's FIRST operation, nil when anonymous. Only
143
- # the fallback for a raw query string handed straight to a transport —
144
- # generated modules pass their OPERATION_NAME, parsed properly.
145
- OPERATION_NAME_PATTERN = /\A\s*(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/
146
- def self.operation_name(query)
147
- query[OPERATION_NAME_PATTERN, 1]
148
- end
149
-
150
- # "[req 3 FilteredPokemon]" — a per-process request id plus the
151
- # operation name, when there is one
152
- REQUEST_MUTEX = Mutex.new
153
-
154
- def self.log_tag(operation_name = nil)
155
- id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
156
- "[req #{id}#{" #{operation_name}" if operation_name}]"
157
- end
158
-
159
- # keep debug readable: a 100-line introspection query would drown the
160
- # log — the INFO introspection line already carries the timing
161
- LOG_QUERY_LIMIT = 600
162
- def self.truncate_for_log(query)
163
- return query if query.length <= LOG_QUERY_LIMIT
164
-
165
- "#{query[0, LOG_QUERY_LIMIT]}... (truncated, #{query.bytesize} bytes total)"
166
- end
167
-
168
145
  private
169
146
 
170
147
  # POST the JSON body to the endpoint; return [status code, raw body]
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.5.1"
2
+ VERSION = "0.6.0"
3
3
  end