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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- 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
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -20,22 +20,25 @@ module GraphWeaver
|
|
|
20
20
|
# carry PII. Auth headers never log.
|
|
21
21
|
attr_accessor :logger
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
data/lib/graph_weaver/parsing.rb
CHANGED
|
@@ -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:
|
|
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
|
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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(
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
50
|
-
|
|
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(
|
|
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) }
|