graph_weaver 0.5.0 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -0,0 +1,206 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+ require "json"
6
+
7
+ require_relative "inflect"
8
+
9
+ module GraphWeaver
10
+ # Not API. Anything under GraphWeaver::Internal is the gem talking to
11
+ # itself and may change or vanish in any release — it is the home for a
12
+ # helper that would otherwise have to go public just because a second file
13
+ # needs it. bin/public-surface skips this namespace, so moving something
14
+ # here is how you take it off the locked public surface.
15
+ #
16
+ # Not private_constant: most of this gem defines its classes compactly
17
+ # (`class GraphWeaver::Codegen`), which puts GraphWeaver outside their
18
+ # lexical scope, so a private constant would be unreachable from exactly
19
+ # the files that need it. The name and the surface lock carry the rule.
20
+ module Internal
21
+ # Odds and ends several files share. Each is here because more than one
22
+ # caller needs it, not because it belongs together with the others.
23
+ module Util
24
+ class << self
25
+ # Replace a file's contents in one step. The schema dump and a
26
+ # cassette are artifacts people commit, and File.write truncates
27
+ # before it writes — so an interrupted run, or a second writer (a
28
+ # rake task beside a running app, two Puma workers), can leave a
29
+ # half-written file that no longer parses. A rename is atomic on
30
+ # POSIX: a reader sees the old file or the new one, never a prefix.
31
+ def atomic_write(path, content)
32
+ tmp = File.join(File.dirname(path),
33
+ ".#{File.basename(path)}.#{Process.pid}.#{Thread.current.object_id}.tmp")
34
+ File.write(tmp, content)
35
+ File.rename(tmp, path)
36
+ ensure
37
+ File.unlink(tmp) if tmp && File.exist?(tmp)
38
+ end
39
+
40
+ # The closest entry in `dictionary` to `term` — a "did you mean"
41
+ # suggestion, or nil (also nil when did_you_mean isn't loadable). One
42
+ # home for the guard used by codegen validation, alias resolution,
43
+ # and the runtime prop hints.
44
+ def did_you_mean(dictionary, term)
45
+ return unless defined?(DidYouMean::SpellChecker)
46
+
47
+ DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
48
+ end
49
+
50
+ # The module a .graphql file generates, and the basename of the file
51
+ # it generates into: the camelized file name plus the operation's own
52
+ # word.
53
+ #
54
+ # person.graphql => PersonQuery (person_query.rb)
55
+ # save_list_entry.graphql => SaveListEntryMutation
56
+ # (save_list_entry_mutation.rb)
57
+ #
58
+ # Every naming site goes through here — generate!, parse(path), and
59
+ # load_queries! — so the constant a file produces is the same one
60
+ # whichever door you came in by, and the file it lands in matches it.
61
+ def generated_names(path, source)
62
+ base = File.basename(path, ".*")
63
+ suffix = operation_suffix(source)
64
+ ["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
65
+ end
66
+
67
+ # just the module name — see generated_names
68
+ def module_name(path, source) = generated_names(path, source).first
69
+
70
+ # A path setting, as a real path: relative to GraphWeaver.root, which
71
+ # is the app root and not wherever the process was started. Every
72
+ # filesystem access on a configured path goes through here; the
73
+ # settings themselves keep returning what was configured, so an error
74
+ # message stays short and graphql.config.yml stays portable.
75
+ def resolve(path) = File.expand_path(path.to_s, GraphWeaver.root)
76
+
77
+ # The other half of that rule: a path this gem REPORTS — returned,
78
+ # logged, or quoted in an error — comes back in the short form the
79
+ # settings use, so a build log reads the same on the next machine and
80
+ # names something you can paste. A path outside the root (an absolute
81
+ # setting) is left as it is: relative to somewhere else it names no file.
82
+ def relative(path)
83
+ path = path.to_s
84
+ prefix = File.join(GraphWeaver.root, "") # trailing separator; "/" stays "/"
85
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : path
86
+ end
87
+
88
+ # Every query document under these directories, sorted — the files
89
+ # generate!, verify_generated!, check_queries and load_queries! read.
90
+ def query_files(paths = GraphWeaver.queries_paths)
91
+ Array(paths).flat_map { |dir| Dir[File.join(resolve(dir), Codegen::DOCUMENT_GLOB)].sort }
92
+ end
93
+
94
+ # The graphql-ruby schema class the app default executes against,
95
+ # when it runs in-process — a Client wrapping one, or the class in
96
+ # the slot bare. nil for every network client. Not memoized: in dev
97
+ # the class object is replaced on reload.
98
+ def live_schema
99
+ # through #transport, not #schema: a url client's #schema
100
+ # introspects, so asking it would answer over the network
101
+ client = GraphWeaver.client
102
+ target = client.is_a?(Client) ? client.transport : client
103
+ target = target.schema if target.is_a?(InProcess)
104
+ target if target.is_a?(Class) && target <= GraphQL::Schema
105
+ end
106
+
107
+ private
108
+
109
+ # "Mutation" for a mutation document, "Query" for everything else.
110
+ def operation_suffix(source)
111
+ operation = GraphQL.parse(source).definitions
112
+ .grep(GraphQL::Language::Nodes::OperationDefinition).first
113
+ (operation&.operation_type == "mutation") ? "Mutation" : "Query"
114
+ rescue GraphQL::ParseError
115
+ "Query" # unparseable: codegen brands the real error a moment later
116
+ end
117
+ end
118
+ end
119
+
120
+ # What makes two GraphQL requests the same request — and how one reads
121
+ # when an error has to quote it. A cassette matches on this, so the
122
+ # rules belong somewhere both the cassette and the error that reports a
123
+ # miss can say, rather than on the class one of them happens to be.
124
+ module RequestKey
125
+ class << self
126
+ # The request's identity, exactly as the server sees it.
127
+ # operationName is part of that: it picks the operation the document
128
+ # runs, so two requests with identical text but different names are
129
+ # different requests. Derived, never stored — the file holds the
130
+ # request once, so a hand-edited entry can't disagree with what
131
+ # replay matches on.
132
+ def for(query, variables, operation_name = nil)
133
+ key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
134
+ key["operationName"] = operation_name if operation_name
135
+ key
136
+ end
137
+
138
+ def for_entry(entry) = self.for(entry["query"], entry["variables"], entry["operationName"])
139
+
140
+ def normalize_query(query) = query.gsub(/\s+/, " ").strip
141
+
142
+ # JSON round-trip so symbol keys become strings — otherwise
143
+ # YAML.dump writes Ruby symbols the safe loader rejects on the next
144
+ # run, and lookup keys stay stable across processes
145
+ def normalize_variables(variables) = JSON.parse(JSON.generate(variables || {}))
146
+
147
+ # one readable line: an error naming a 60-line query is a wall, not a hint
148
+ def summarize(query, limit: 160)
149
+ normalized = normalize_query(query)
150
+ (normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
151
+ end
152
+ end
153
+ end
154
+
155
+ # The GraphQL wire format, either direction: what a request document
156
+ # says about itself, how the log refers to it, and the shape a response
157
+ # carries an error in. Lived on Transport and Router, both of which
158
+ # users touch — the worst place for it.
159
+ module Wire
160
+ # The name of the document's FIRST operation, nil when anonymous. Only
161
+ # the fallback for a raw query string handed straight to a transport —
162
+ # generated modules pass their OPERATION_NAME, parsed properly.
163
+ OPERATION_NAME_PATTERN = /\A\s*(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/
164
+ private_constant :OPERATION_NAME_PATTERN
165
+
166
+ # Whether this document's operation writes — what Retry asks before
167
+ # repeating a request. Line-anchored rather than parsed: it runs on
168
+ # every request, and the only way to be wrong (a field literally named
169
+ # `mutation` opening a line) errs toward not retrying.
170
+ MUTATION_PATTERN = /^[ \t]*mutation\b/
171
+ private_constant :MUTATION_PATTERN
172
+
173
+ REQUEST_MUTEX = Mutex.new
174
+ private_constant :REQUEST_MUTEX
175
+
176
+ # keep debug readable: a 100-line introspection query would drown the
177
+ # log — the INFO introspection line already carries the timing
178
+ LOG_QUERY_LIMIT = 600
179
+ private_constant :LOG_QUERY_LIMIT
180
+
181
+ class << self
182
+ def operation_name(query) = query[OPERATION_NAME_PATTERN, 1]
183
+
184
+ def mutation?(query) = MUTATION_PATTERN.match?(query)
185
+
186
+ # one error in the shape a GraphQL response carries them
187
+ def graphql_error(message, code)
188
+ { "message" => message, "extensions" => { "code" => code } }
189
+ end
190
+
191
+ # "[req 3 FilteredPokemon]" — a per-process request id plus the
192
+ # operation name, when there is one
193
+ def log_tag(operation_name = nil)
194
+ id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
195
+ "[req #{id}#{" #{operation_name}" if operation_name}]"
196
+ end
197
+
198
+ def truncate_for_log(query)
199
+ return query if query.length <= LOG_QUERY_LIMIT
200
+
201
+ "#{query[0, LOG_QUERY_LIMIT]}... (truncated, #{query.bytesize} bytes total)"
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -20,22 +20,25 @@ module GraphWeaver
20
20
  # carry PII. Auth headers never log.
21
21
  attr_accessor :logger
22
22
 
23
- # Internal: level-gated and lazy the block only runs when a logger
24
- # is listening. Messages carry "graph_weaver" as progname.
25
- def log(level, &block)
26
- logger&.public_send(level, "graph_weaver", &block)
27
- end
23
+ # What never reaches the log when variables are written at debug. A
24
+ # list of keys Strings/Symbols match as case-insensitive substrings
25
+ # (`:token` covers `apiToken`), Regexps match themselves — applied at
26
+ # every depth, matched values replaced with "[FILTERED]":
27
+ #
28
+ # GraphWeaver.filter_parameters = [:password, /token/]
29
+ #
30
+ # Rails apps need none of this: the railtie hands over the app's own
31
+ # config.filter_parameters. Anything answering #filter(hash) is taken
32
+ # as-is, which is how an ActiveSupport::ParameterFilter gets in.
33
+ attr_reader :filter_parameters
28
34
 
29
- # Internal: run the block, logging "<label> (Nms)" at level — timing
30
- # skipped entirely when no logger is set. Returns the block's value.
31
- def log_timed(level, label)
32
- return yield unless logger
35
+ def filter_parameters=(filters)
36
+ unless filters.nil? || filters.is_a?(Array) || filters.respond_to?(:filter)
37
+ raise ArgumentError,
38
+ "filter_parameters: takes a list of keys, or an object answering #filter — got #{filters.inspect}"
39
+ end
33
40
 
34
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
- result = yield
36
- ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
37
- log(level) { "#{label} (#{ms}ms)" }
38
- result
41
+ @filter_parameters = filters
39
42
  end
40
43
 
41
44
  # One callable wrapping every request GraphWeaver makes — over the
@@ -53,18 +56,103 @@ module GraphWeaver
53
56
  # lands — :status. Never the query text or the variables: those
54
57
  # carry PII and belong at debug on the logger, where they're gated.
55
58
  attr_accessor :instrumenter
59
+ end
56
60
 
57
- # Internal: wrap the block in the instrumenter, if one is set. The
58
- # payload is a plain Hash the caller may add to inside the block.
59
- def instrument(event, payload)
60
- hook = instrumenter
61
- return yield unless hook
61
+ module Internal
62
+ # The message side of filter_parameters. One rule: a message the library
63
+ # composes about a value the caller supplied names that value only when
64
+ # the key it arrived under isn't filtered — so a password's rejection
65
+ # reads "[FILTERED]" in the exception, and in the warn line Error#initialize
66
+ # writes, exactly as it does in the debug log.
67
+ module Redact
68
+ class << self
69
+ # True when a value under this key must not appear in a message.
70
+ # Asked of filter_variables rather than of the list, so the
71
+ # ActiveSupport::ParameterFilter a Rails app hands over answers too.
72
+ def filtered?(key)
73
+ !key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
74
+ end
62
75
 
63
- hook.call(event, payload) { yield }
76
+ # `detail` unless the key is filtered — free text a coercer or sorbet
77
+ # wrote can spell a value any way, so for a filtered key none of it
78
+ # survives, not the parts that would have been safe.
79
+ def detail(key, detail) = filtered?(key) ? FILTERED : detail
80
+ end
64
81
  end
65
82
  end
66
83
 
84
+ # Rails' spelling, so a scrubbed log reads the same either side of the seam
85
+ FILTERED = "[FILTERED]"
86
+
87
+ # Safe before anyone configures anything; substring matching means these
88
+ # already cover apiToken, client_secret, password_confirmation…
89
+ DEFAULT_FILTER_PARAMETERS = %i[password token secret authorization].freeze
90
+
91
+ self.filter_parameters = DEFAULT_FILTER_PARAMETERS
92
+
67
93
  # The one instrumentation event: a single GraphQL request, start to
68
94
  # parsed response, whichever client slot served it.
69
95
  EXECUTE_EVENT = "graph_weaver.execute"
96
+
97
+ module Internal
98
+ # The emitting half of the narration the three accessors above
99
+ # configure. Setting a logger is API; writing to it is not, and the
100
+ # two read as a pair when they sit on the same object.
101
+ module Log
102
+ class << self
103
+ # Level-gated and lazy — the block only runs when a logger is
104
+ # listening. Messages carry "graph_weaver" as progname.
105
+ def log(level, &block)
106
+ GraphWeaver.logger&.public_send(level, "graph_weaver", &block)
107
+ end
108
+
109
+ # Run the block, logging "<label> (Nms)" at level — timing skipped
110
+ # entirely when no logger is set. Returns the block's value.
111
+ def log_timed(level, label)
112
+ return yield unless GraphWeaver.logger
113
+
114
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ result = yield
116
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
117
+ log(level) { "#{label} (#{ms}ms)" }
118
+ result
119
+ end
120
+
121
+ # Wrap the block in the instrumenter, if one is set. The payload is
122
+ # a plain Hash the caller may add to inside the block.
123
+ def instrument(event, payload)
124
+ hook = GraphWeaver.instrumenter
125
+ return yield unless hook
126
+
127
+ hook.call(event, payload) { yield }
128
+ end
129
+
130
+ # variables with the filtered keys blanked out
131
+ def filter_variables(variables)
132
+ filters = GraphWeaver.filter_parameters
133
+ # Array before the duck-type check: Array#filter is Enumerable's, not ours
134
+ return filters.empty? ? variables : scrub(variables, filters) if filters.is_a?(Array)
135
+
136
+ filters.nil? ? variables : filters.filter(variables)
137
+ end
138
+
139
+ private
140
+
141
+ def scrub(value, filters)
142
+ case value
143
+ when Hash then value.to_h { |k, v| [k, filtered?(k, filters) ? FILTERED : scrub(v, filters)] }
144
+ when Array then value.map { |v| scrub(v, filters) }
145
+ else value
146
+ end
147
+ end
148
+
149
+ def filtered?(key, filters)
150
+ name = key.to_s
151
+ filters.any? do |filter|
152
+ filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
70
158
  end
@@ -4,6 +4,7 @@
4
4
  require "sorbet-runtime"
5
5
 
6
6
  require_relative "codegen"
7
+ require_relative "internal"
7
8
 
8
9
  module GraphWeaver
9
10
  # Anything that holds a schema parses against it. That's a Client, an
@@ -27,7 +28,7 @@ module GraphWeaver
27
28
  def parse(query, name: nil)
28
29
  # #schema is the mixin's one requirement of its includer, and a module
29
30
  # has no way to declare that short of an abstract interface
30
- GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: parse_client)
31
+ GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: self)
31
32
  end
32
33
 
33
34
  # Parse every query in a directory (subdirectories included) into typed
@@ -41,27 +42,19 @@ module GraphWeaver
41
42
  # Reloadable (constants are replaced), so it suits consoles and dev.
42
43
  # Returns the modules.
43
44
  def load_queries!(dir = nil, namespace: Object)
44
- GraphWeaver.query_files(dir || GraphWeaver.queries_paths).map do |path|
45
- name = GraphWeaver.module_name(path, File.read(path))
45
+ GraphWeaver::Internal::Util.query_files(dir || GraphWeaver.queries_paths).map do |path|
46
+ name = GraphWeaver::Internal::Util.module_name(path, File.read(path))
46
47
  if namespace.const_defined?(name, false)
47
48
  # the constant moves, its instances don't — a struct built before the
48
49
  # reload keeps failing is_a? against the new module, silently
49
- GraphWeaver.log(:info) do
50
+ GraphWeaver::Internal::Log.log(:info) do
50
51
  "replacing #{name} — objects built from the previous module stay instances of it"
51
52
  end
52
53
  namespace.send(:remove_const, name)
53
54
  end
54
- GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
55
+ GraphWeaver::Internal::Log.log(:info) { "loaded #{name} from #{GraphWeaver::Internal::Util.relative(path)}" }
55
56
  namespace.const_set(name, parse(path))
56
57
  end
57
58
  end
58
-
59
- private
60
-
61
- # What a parsed module executes through: this object, which holds the
62
- # schema and runs queries. Client is the one that overrides it — its own
63
- # #execute is the one-shot parse-and-run, not the client contract, so it
64
- # bakes the transport it wraps.
65
- def parse_client = self
66
59
  end
67
60
  end
@@ -4,6 +4,8 @@
4
4
  require "sorbet-runtime"
5
5
 
6
6
  module GraphWeaver
7
+ # Called by generated code — not semver'd for direct use.
8
+ #
7
9
  # Runtime for generated query modules: the client plumbing, which is the
8
10
  # one part of a generated module that carries no per-query type
9
11
  # information — every module's copy was identical. `extend
@@ -7,15 +7,27 @@
7
7
  # rake_tasks block, so graph_weaver:* tasks appear with no Rakefile
8
8
  # edit. (Outside Rails there is no task-discovery hook — add
9
9
  # `require "graph_weaver/tasks"` to your Rakefile.)
10
- # - generated modules: required at boot when a generated_paths entry exists,
11
- # after config/initializers (registrations and GraphWeaver.client=
12
- # run first block-built type helpers must exist before the files
13
- # that include them load). load_generated! stays idempotent, so
14
- # calling it yourself too is harmless.
10
+ # - generated modules: required at boot once every registration has run —
11
+ # both the initializer kind and the to_prepare kind, since a generated
12
+ # file `include`s the type helper it was generated with and that
13
+ # constant must resolve. load_generated! stays idempotent, so calling
14
+ # it yourself too is harmless.
15
15
  # - Zeitwerk: the generated directory is hidden from it, since the
16
16
  # default one lives under app/ and its files define top-level
17
17
  # constants.
18
+ # - watch mode: in development, editing a .graphql regenerates before the
19
+ # next request, the way editing a route or a locale takes effect.
18
20
  class GraphWeaver::Railtie < Rails::Railtie
21
+ # config.graph_weaver.watch — false to never regenerate during a request.
22
+ # Default: development only.
23
+ config.graph_weaver = ActiveSupport::OrderedOptions.new
24
+
25
+ class << self
26
+ # The file watcher, so the to_prepare block below can ask it whether a
27
+ # query changed. nil when not watching.
28
+ attr_accessor :watcher
29
+ end
30
+
19
31
  rake_tasks do
20
32
  require "graph_weaver/tasks"
21
33
  end
@@ -29,7 +41,7 @@ class GraphWeaver::Railtie < Rails::Railtie
29
41
  Rails.autoloaders.each do |loader|
30
42
  # patterns, not paths — generated_paths may be globs, and Zeitwerk
31
43
  # expands its own at setup (which is what this runs before)
32
- GraphWeaver.generated_paths.each { |path| loader.ignore(Rails.root.join(path).to_s) }
44
+ GraphWeaver.generated_paths.each { |path| loader.ignore(GraphWeaver::Internal::Util.resolve(path)) }
33
45
  end
34
46
  end
35
47
 
@@ -39,14 +51,101 @@ class GraphWeaver::Railtie < Rails::Railtie
39
51
  GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
40
52
  end
41
53
 
42
- initializer "graph_weaver.load_generated", after: :load_config_initializers do
43
- # The graph_weaver tasks write these files and need none of them loaded.
44
- # Loading them would let a stale one block its own repair: a dropped
45
- # extend_type leaves a dangling include, and generate depends on
46
- # :environment, so boot failed before the task that would regenerate it.
47
- next if GraphWeaver.skip_generated_load
54
+ # The app already declared what is sensitive, so variables logged at debug
55
+ # honour the same list as its request logs including the Procs and dotted
56
+ # paths only ParameterFilter understands. after: :load_config_initializers,
57
+ # since filter_parameter_logging.rb is where an app adds to it.
58
+ initializer "graph_weaver.filter_parameters", after: :load_config_initializers do |app|
59
+ filters = app.config.filter_parameters
60
+ next if filters.empty? || GraphWeaver.filter_parameters != GraphWeaver::DEFAULT_FILTER_PARAMETERS
61
+
62
+ GraphWeaver.filter_parameters = ActiveSupport::ParameterFilter.new(filters)
63
+ end
64
+
65
+ # Watch mode. A .graphql edit should reach the next request the way a route
66
+ # or a locale change does, so the query directories and the schema dump
67
+ # become one of Rails' own reloaders: a change there alone triggers a reload
68
+ # cycle, and the to_prepare below regenerates before it loads. Off with
69
+ #
70
+ # config.graph_weaver.watch = false
71
+ #
72
+ # after: :load_config_initializers — that's where an app moves
73
+ # queries_paths, and the finisher that reads app.reloaders runs later still.
74
+ initializer "graph_weaver.watch", after: :load_config_initializers do |app|
75
+ GraphWeaver::Railtie.watch!(app)
76
+ end
77
+
78
+ # Registers the watcher, and says so: this is the one thing GraphWeaver does
79
+ # that writes a checked-in file outside a rake task. Returns it, or nil when
80
+ # nothing is being watched.
81
+ def self.watch!(app)
82
+ watch = app.config.graph_weaver.watch
83
+ watch = Rails.env.development? if watch.nil?
84
+ # with reloading off nothing re-runs to_prepare, so a watcher could only
85
+ # promise something it can't do
86
+ return self.watcher = nil unless watch && app.config.reloading_enabled?
48
87
 
49
- # entries may be globs, so Dir[] rather than Dir.exist?
50
- GraphWeaver.load_generated! if GraphWeaver.generated_paths.any? { |path| Dir[path].any? }
88
+ # a directory that doesn't exist yet is still watched — FileUpdateChecker
89
+ # re-globs on every check, and its keys may themselves be globs
90
+ watched = GraphWeaver.queries_paths + GraphWeaver.fragments_paths
91
+ # the dump codegen would read, or where it goes once someone takes one
92
+ dump = GraphWeaver::SchemaLoader.locate_path || GraphWeaver.schema_path
93
+
94
+ dirs = watched.to_h { |path| [GraphWeaver::Internal::Util.resolve(path), %w[graphql gql]] }
95
+ self.watcher = app.config.file_watcher.new([GraphWeaver::Internal::Util.resolve(dump)], dirs) { regenerate! }
96
+ app.reloaders << watcher
97
+ GraphWeaver::Internal::Log.log(:info) do
98
+ "watching #{(watched << GraphWeaver::Internal::Util.relative(dump)).join(", ")} — an edit regenerates " \
99
+ "#{GraphWeaver.generated_paths.first} before the next request " \
100
+ "(config.graph_weaver.watch = false to stop)"
101
+ end
102
+ watcher
103
+ end
104
+
105
+ # Regenerate in place, and keep serving when a query doesn't compile: a file
106
+ # saved mid-edit shouldn't take the dev server down, and the modules already
107
+ # loaded are the ones that worked a keystroke ago. Nothing is half-written on
108
+ # that path — generation validates every query before it writes any file — so
109
+ # one error per save, and the next save that compiles takes.
110
+ def self.regenerate!
111
+ GraphWeaver.generate!
112
+ GraphWeaver.reload_generated!
113
+ changed = GraphWeaver.changed_files
114
+ GraphWeaver::Internal::Log.log(:info) do
115
+ next "generated modules already up to date" if changed.empty?
116
+
117
+ "regenerated #{changed.join(", ")}"
118
+ end
119
+ rescue GraphWeaver::Error => e
120
+ GraphWeaver::Internal::Log.log(:error) { "keeping the modules already loaded — #{e.message}" }
121
+ end
122
+
123
+ # A generated file `include`s the type helper it was generated with, so it
124
+ # can't load until that constant resolves — and both Zeitwerk's setup and
125
+ # the app's own to_prepare blocks (where extend_type/register_enum are told
126
+ # to register, Codegen::AUTOLOAD_HINT) happen after config/initializers.
127
+ # to_prepare, not `after:` a finisher initializer: naming one there makes
128
+ # tsort hoist it ahead of the app's own config/initializers. Re-running on
129
+ # each dev reload is free — require is idempotent — and picks up a module
130
+ # generated since boot.
131
+ initializer "graph_weaver.load_generated", after: :load_config_initializers do |app|
132
+ app.config.to_prepare do
133
+ # The graph_weaver tasks write these files and need none of them loaded.
134
+ # Loading them would let a stale one block its own repair: a dropped
135
+ # extend_type leaves a dangling include, and generate depends on
136
+ # :environment, so boot failed before the task that would regenerate it.
137
+ next if GraphWeaver.skip_generated_load
138
+
139
+ # Regenerate first, then load — and here rather than in the watcher's own
140
+ # to_run, so an extend_type or register_enum the app registers in its own
141
+ # to_prepare is already in place (that block was registered at
142
+ # :load_config_initializers, so it has run by now). A run that
143
+ # regenerated has already reloaded what it wrote.
144
+ next if GraphWeaver::Railtie.watcher&.execute_if_updated
145
+
146
+ # entries may be globs, so Dir[] rather than Dir.exist?
147
+ generated = GraphWeaver.generated_paths.any? { |dir| Dir[GraphWeaver::Internal::Util.resolve(dir)].any? }
148
+ GraphWeaver.load_generated! if generated
149
+ end
51
150
  end
52
151
  end
@@ -4,6 +4,8 @@
4
4
  require_relative "errors"
5
5
 
6
6
  module GraphWeaver
7
+ # Called by generated code — not semver'd for direct use.
8
+ #
7
9
  # Runtime for the generated `Representations` builders — the entity
8
10
  # references a federation `_entities(representations:)` query takes.
9
11
  #
@@ -13,6 +15,10 @@ module GraphWeaver
13
15
  # resolves it, neither is individually required) and a nested key set
14
16
  # (`organization { id }`, a sub-hash the kwarg's Hash type doesn't pin
15
17
  # down). Both land here.
18
+ #
19
+ # `InputError#struct` is the entity's GraphQL type name here, not a class: a
20
+ # representation is a Hash a module function builds, so there is no generated
21
+ # struct to name.
16
22
  module Representation
17
23
  # `key_sets` is the entity's @key field sets as dotted paths, in
18
24
  # declaration order — [["upc", "sku"], ["id"]] for a type keyed either
@@ -27,6 +33,25 @@ module GraphWeaver
27
33
  end
28
34
  end
29
35
 
36
+ # One key field's trip onto the wire: normalize whatever arrived into the
37
+ # type the sig promises — the sig itself is `.checked(:never)`, so this is
38
+ # the check — then serialize. A cast complains about the value alone
39
+ # ("expected an Int"), and a query can build several representations, so
40
+ # the refusal names this one and the field. nil passes through untouched:
41
+ # a missing key is `build`'s complaint to make, and it says more.
42
+ def self.field(type_name, name, value)
43
+ return if value.nil?
44
+
45
+ yield value
46
+ rescue StandardError => e
47
+ shown = value.inspect
48
+ got = " (got #{shown})" unless e.message.include?(shown)
49
+ raise InputError.new(
50
+ "#{type_name} representation #{name}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}",
51
+ field: name, struct: type_name,
52
+ )
53
+ end
54
+
30
55
  def self.missing(values, paths) = paths.select { |path| dig(values, path).nil? }
31
56
  private_class_method :missing
32
57
 
@@ -57,7 +82,7 @@ module GraphWeaver
57
82
  if key_sets.one?
58
83
  InputError.new(
59
84
  "#{type_name} representation is missing @key #{gaps.first.map(&:inspect).join(", ")}",
60
- field: gaps.first.one? ? gaps.first.first : nil,
85
+ field: gaps.first.one? ? gaps.first.first : nil, struct: type_name,
61
86
  )
62
87
  else
63
88
  alternatives = key_sets.zip(gaps).map do |paths, gap|
@@ -66,7 +91,10 @@ module GraphWeaver
66
91
  supplied = gap.size < paths.size
67
92
  "#{paths.map(&:inspect).join(" + ")}#{" (missing #{gap.map(&:inspect).join(", ")})" if supplied}"
68
93
  end
69
- InputError.new("#{type_name} representation satisfies none of its @keys — supply #{alternatives.join(", or ")}")
94
+ InputError.new(
95
+ "#{type_name} representation satisfies none of its @keys — supply #{alternatives.join(", or ")}",
96
+ struct: type_name,
97
+ )
70
98
  end
71
99
  end
72
100
  private_class_method :incomplete
@@ -51,6 +51,21 @@ module GraphWeaver
51
51
  sig { returns(T::Boolean) }
52
52
  def success? = errors.empty?
53
53
 
54
+ # The envelope decomposed, string-keyed like the error classes' #to_h:
55
+ # errors become JSON-ready hashes, extensions pass through, and data
56
+ # stays the typed struct.
57
+ #
58
+ # Data is NOT re-serialized: T::Struct#serialize is the wrong inverse
59
+ # here — props are snake_case where the wire is camelCase, nil fields
60
+ # drop out, and a registered scalar keeps whatever Ruby object its codec
61
+ # built. The result would look like the server's response and not be one.
62
+ # Serialize the typed data yourself, or keep the raw hash and hand it to
63
+ # .from_response when you need both.
64
+ sig { returns(T::Hash[String, T.untyped]) }
65
+ def to_h
66
+ { "data" => data, "errors" => errors.map(&:to_h), "extensions" => extensions }
67
+ end
68
+
54
69
  # The typed result, or raise QueryError if the response carried top-level
55
70
  # errors (partial data and extensions ride along on the error).
56
71
  sig { returns(Data) }