fragment-dev 2.0.0 → 2.1.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/lib/fragment.schema.json +824 -103
- data/lib/fragment_client/graphql_ast.rb +73 -0
- data/lib/fragment_client/typed_entries.rb +444 -0
- data/lib/fragment_client/typed_ledger_entry.rb +281 -0
- data/lib/fragment_client/version.rb +1 -1
- data/lib/fragment_client.rb +169 -16
- data/lib/queries.graphql +46 -0
- data/lib/tapioca/dsl/compilers/fragment_query_methods.rb +61 -0
- data/lib/tapioca/dsl/compilers/fragment_response_types.rb +222 -0
- data/lib/tapioca/dsl/compilers/fragment_typed_entries.rb +169 -0
- data/lib/tapioca/dsl/helpers/graphql_sorbet_types.rb +53 -0
- metadata +9 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'graphql'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
class FragmentClient
|
|
8
|
+
# Reading helpers for graphql-ruby's language AST.
|
|
9
|
+
#
|
|
10
|
+
# Nothing here knows about Fragment. Kept separate so it can move to a gem of
|
|
11
|
+
# its own if a second consumer appears -- no such gem exists today.
|
|
12
|
+
#
|
|
13
|
+
# Named `GraphqlAst` rather than `GraphQL::Ast`: a `FragmentClient::GraphQL`
|
|
14
|
+
# would shadow the real `GraphQL` for every constant lookup inside
|
|
15
|
+
# {FragmentClient}.
|
|
16
|
+
module GraphqlAst
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
extend T::Sig
|
|
21
|
+
|
|
22
|
+
# The operation's only root field, if it is a field with that name.
|
|
23
|
+
#
|
|
24
|
+
# `nil` for the wrong operation type, more than one selection, or a
|
|
25
|
+
# selection that is a fragment spread rather than a field.
|
|
26
|
+
sig do
|
|
27
|
+
params(operation: GraphQL::Language::Nodes::OperationDefinition,
|
|
28
|
+
field_name: String, operation_type: String)
|
|
29
|
+
.returns(T.nilable(GraphQL::Language::Nodes::Field))
|
|
30
|
+
end
|
|
31
|
+
def single_root_field(operation, field_name, operation_type: 'mutation')
|
|
32
|
+
return nil unless operation.operation_type == operation_type
|
|
33
|
+
|
|
34
|
+
selections = operation.selections
|
|
35
|
+
return nil unless selections.length == 1
|
|
36
|
+
|
|
37
|
+
root = selections.first
|
|
38
|
+
return nil unless root.is_a?(GraphQL::Language::Nodes::Field)
|
|
39
|
+
|
|
40
|
+
root.name == field_name ? root : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# An argument's value when it is written as an inline object literal, as
|
|
44
|
+
# opposed to passed as a variable or absent.
|
|
45
|
+
sig do
|
|
46
|
+
params(field: GraphQL::Language::Nodes::Field, argument_name: String)
|
|
47
|
+
.returns(T.nilable(GraphQL::Language::Nodes::InputObject))
|
|
48
|
+
end
|
|
49
|
+
def inline_object_argument(field, argument_name)
|
|
50
|
+
value = field.arguments.find { |argument| argument.name == argument_name }&.value
|
|
51
|
+
value.is_a?(GraphQL::Language::Nodes::InputObject) ? value : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One field of an inline object literal, or `nil` if absent. A field written
|
|
55
|
+
# as `null` yields a `NullValue` node, not `nil`.
|
|
56
|
+
sig do
|
|
57
|
+
params(object: GraphQL::Language::Nodes::InputObject, name: String).returns(T.untyped)
|
|
58
|
+
end
|
|
59
|
+
def object_field(object, name)
|
|
60
|
+
object.arguments.find { |argument| argument.name == name }&.value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Variable name to declared type node.
|
|
64
|
+
sig do
|
|
65
|
+
params(operation: GraphQL::Language::Nodes::OperationDefinition)
|
|
66
|
+
.returns(T::Hash[String, T.untyped])
|
|
67
|
+
end
|
|
68
|
+
def variable_types(operation)
|
|
69
|
+
operation.variables.to_h { |definition| [definition.name, definition.type] }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'graphql'
|
|
5
|
+
require 'logger'
|
|
6
|
+
require 'sorbet-runtime'
|
|
7
|
+
|
|
8
|
+
require 'fragment_client/graphql_ast'
|
|
9
|
+
require 'fragment_client/typed_ledger_entry'
|
|
10
|
+
|
|
11
|
+
class FragmentClient
|
|
12
|
+
# Namespace holding the derived payload classes, e.g.
|
|
13
|
+
# `FragmentClient::Entries::AuthCaptureV1`.
|
|
14
|
+
#
|
|
15
|
+
# Every constant here is defined at load time by {FragmentClient::TypedEntries.load};
|
|
16
|
+
# anything else would risk colliding with a Ledger Entry type.
|
|
17
|
+
module Entries; end
|
|
18
|
+
|
|
19
|
+
# Derives typed `addLedgerEntries` payloads from the per-entry-type
|
|
20
|
+
# `addLedgerEntry` operations the Fragment CLI generates for a Schema.
|
|
21
|
+
#
|
|
22
|
+
# A generated operation names the entry type as a string literal and binds each
|
|
23
|
+
# parameter to a typed variable, which is what `addLedgerEntries` alone cannot
|
|
24
|
+
# express: its `parameters` is an opaque `JSON` scalar.
|
|
25
|
+
#
|
|
26
|
+
# FragmentClient::TypedEntries.load('app/graphql/entries.graphql')
|
|
27
|
+
# entry = FragmentClient::Entries::AuthCaptureV1.new(
|
|
28
|
+
# ik: 'ik-1', ledger_ik: 'prod', capture_amount: '100'
|
|
29
|
+
# )
|
|
30
|
+
# client.add_ledger_entries(entries: [entry])
|
|
31
|
+
#
|
|
32
|
+
# Payload classes are built at load time, so Sorbet sees them only through the
|
|
33
|
+
# RBI `bundle exec tapioca dsl` generates.
|
|
34
|
+
#
|
|
35
|
+
# Implements `typed-batch-entries.md` from `fragment-dev/graphql-queries`.
|
|
36
|
+
# Section references throughout are to that spec; `docs/spec-conformance.md`
|
|
37
|
+
# maps it onto this SDK and explains the choices.
|
|
38
|
+
module TypedEntries
|
|
39
|
+
extend T::Sig
|
|
40
|
+
|
|
41
|
+
# The only field a typed entry operation may select (spec 2.1).
|
|
42
|
+
ADD_LEDGER_ENTRY_FIELD = 'addLedgerEntry'
|
|
43
|
+
|
|
44
|
+
# What an entry with no `typeVersion` resolves to server-side (spec 2.5).
|
|
45
|
+
DEFAULT_TYPE_VERSION = 1
|
|
46
|
+
|
|
47
|
+
# Marks a keyword the caller omitted, as distinct from one set to `nil`
|
|
48
|
+
# (spec 3.2). Internal to {TypedLedgerEntry#initialize}; never returned.
|
|
49
|
+
class Unset
|
|
50
|
+
extend T::Sig
|
|
51
|
+
|
|
52
|
+
sig { returns(String) }
|
|
53
|
+
def inspect
|
|
54
|
+
'FragmentClient::TypedEntries::UNSET'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
alias to_s inspect
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
UNSET = T.let(Unset.new.freeze, Unset)
|
|
61
|
+
|
|
62
|
+
# One templated parameter of a typed Ledger Entry.
|
|
63
|
+
class Parameter < T::Struct
|
|
64
|
+
extend T::Sig
|
|
65
|
+
|
|
66
|
+
# The Schema's name for it, and the JSON key sent in `parameters` (spec 3.3).
|
|
67
|
+
const :wire_name, String
|
|
68
|
+
|
|
69
|
+
# The keyword argument and reader on the payload class. Differs from
|
|
70
|
+
# `wire_name` only when that name is taken (spec 2.5).
|
|
71
|
+
const :name, Symbol
|
|
72
|
+
|
|
73
|
+
# The bound variable's GraphQL type as written, e.g. `String!`,
|
|
74
|
+
# `[SafeString!]`. Read by the Tapioca compiler; unused at runtime.
|
|
75
|
+
const :graphql_type, String
|
|
76
|
+
|
|
77
|
+
# Whether the bound variable is non-null.
|
|
78
|
+
const :required, T::Boolean
|
|
79
|
+
|
|
80
|
+
sig { returns(T::Boolean) }
|
|
81
|
+
def escaped?
|
|
82
|
+
wire_name.to_sym != name
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# By value. `T::Struct` otherwise compares by identity.
|
|
86
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
87
|
+
def ==(other)
|
|
88
|
+
other.is_a?(Parameter) && serialize == other.serialize
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Everything needed to define one payload class.
|
|
93
|
+
class EntrySpec < T::Struct
|
|
94
|
+
extend T::Sig
|
|
95
|
+
|
|
96
|
+
const :entry_type, String
|
|
97
|
+
|
|
98
|
+
# Always concrete; an unpinned operation carries {DEFAULT_TYPE_VERSION}.
|
|
99
|
+
const :type_version, Integer
|
|
100
|
+
|
|
101
|
+
# The operation this came from. Names the class only on collision (spec 2.5).
|
|
102
|
+
const :operation_name, String
|
|
103
|
+
|
|
104
|
+
# In the order they appear in the source `parameters: {...}` (spec 2.4).
|
|
105
|
+
const :parameters, T::Array[Parameter]
|
|
106
|
+
|
|
107
|
+
# What a payload is keyed on: the pair, never the entry type alone, since
|
|
108
|
+
# one type at two versions has two parameter sets (spec 2.2).
|
|
109
|
+
sig { returns([String, Integer]) }
|
|
110
|
+
def identity
|
|
111
|
+
[entry_type, type_version]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# `<PascalEntryType>V<version>`. Always carries the version, and depends on
|
|
115
|
+
# nothing but this payload's own identity (spec 2.5, 2.6).
|
|
116
|
+
sig { returns(String) }
|
|
117
|
+
def class_name
|
|
118
|
+
"#{TypedEntries.constant_name(entry_type)}V#{type_version}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
sig { params(other: EntrySpec).returns(T::Boolean) }
|
|
122
|
+
def same_parameters?(other)
|
|
123
|
+
parameters == other.parameters
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Error < StandardError; end
|
|
128
|
+
|
|
129
|
+
# Raised when a batch is built for an entry type that was never loaded.
|
|
130
|
+
class UnknownEntryTypeError < Error; end
|
|
131
|
+
|
|
132
|
+
class << self
|
|
133
|
+
extend T::Sig
|
|
134
|
+
|
|
135
|
+
# Derive payload classes from `.graphql` documents and define them under
|
|
136
|
+
# `namespace`.
|
|
137
|
+
#
|
|
138
|
+
# Idempotent, and needs neither credentials nor network, so it is safe in an
|
|
139
|
+
# initializer -- where `tapioca dsl` will see the classes.
|
|
140
|
+
sig do
|
|
141
|
+
params(paths: String, namespace: Module)
|
|
142
|
+
.returns(T::Array[T.class_of(FragmentClient::TypedLedgerEntry)])
|
|
143
|
+
end
|
|
144
|
+
def load(*paths, namespace: Entries)
|
|
145
|
+
paths.flat_map { |path| load_string(File.read(path), namespace: namespace, origin: path) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# {load}, for a document already in memory.
|
|
149
|
+
sig do
|
|
150
|
+
params(source: String, namespace: Module, origin: T.nilable(String))
|
|
151
|
+
.returns(T::Array[T.class_of(FragmentClient::TypedLedgerEntry)])
|
|
152
|
+
end
|
|
153
|
+
def load_string(source, namespace: Entries, origin: nil)
|
|
154
|
+
define(extract(GraphQL.parse(source)), namespace: namespace, origin: origin)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The payload class for an entry type and version.
|
|
158
|
+
#
|
|
159
|
+
# @raise [UnknownEntryTypeError] if no document declaring it was loaded.
|
|
160
|
+
sig do
|
|
161
|
+
params(entry_type: String, type_version: Integer)
|
|
162
|
+
.returns(T.class_of(FragmentClient::TypedLedgerEntry))
|
|
163
|
+
end
|
|
164
|
+
def fetch(entry_type, type_version = DEFAULT_TYPE_VERSION)
|
|
165
|
+
registry.fetch([entry_type, type_version]) do
|
|
166
|
+
raise UnknownEntryTypeError,
|
|
167
|
+
"No typed payload loaded for Ledger Entry type #{entry_type.inspect} " \
|
|
168
|
+
"version #{type_version}. Pass the .graphql file declaring its " \
|
|
169
|
+
'addLedgerEntry operation to FragmentClient::TypedEntries.load.'
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Every loaded payload class, keyed by `[entry type, version]`.
|
|
174
|
+
sig { returns(T::Hash[[String, Integer], T.class_of(FragmentClient::TypedLedgerEntry)]) }
|
|
175
|
+
def registry
|
|
176
|
+
@registry ||= T.let({}, T.nilable(T::Hash[[String, Integer],
|
|
177
|
+
T.class_of(FragmentClient::TypedLedgerEntry)]))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Forget every loaded payload class and remove its constant. For tests.
|
|
181
|
+
sig { void }
|
|
182
|
+
def reset!
|
|
183
|
+
defined_constants.each do |namespace, name|
|
|
184
|
+
namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
|
|
185
|
+
end
|
|
186
|
+
defined_constants.clear
|
|
187
|
+
registry.clear
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Convert typed payloads to `AddLedgerEntryInput` hashes, in order. Raw
|
|
191
|
+
# hashes pass through untouched (spec 3.1, 3.5).
|
|
192
|
+
sig { params(entries: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
|
193
|
+
def to_entry_inputs(entries)
|
|
194
|
+
entries.map do |entry|
|
|
195
|
+
entry.is_a?(FragmentClient::TypedLedgerEntry) ? entry.to_entry_input : entry
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
sig { returns(Logger) }
|
|
200
|
+
def logger
|
|
201
|
+
FragmentClient.configuration.logger
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sig { returns(Thread::Mutex) }
|
|
205
|
+
def lock
|
|
206
|
+
@lock ||= T.let(Thread::Mutex.new, T.nilable(Thread::Mutex))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# `[namespace, constant name]` for every class {load} defined, so {reset!}
|
|
210
|
+
# removes exactly those.
|
|
211
|
+
sig { returns(T::Array[[Module, String]]) }
|
|
212
|
+
def defined_constants
|
|
213
|
+
@defined_constants ||= T.let([], T.nilable(T::Array[[Module, String]]))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --- Derivation (spec 2) ------------------------------------------------
|
|
217
|
+
|
|
218
|
+
# A spec per typed entry operation, deduplicated on identity.
|
|
219
|
+
sig { params(document: GraphQL::Language::Nodes::Document).returns(T::Array[EntrySpec]) }
|
|
220
|
+
def extract(document)
|
|
221
|
+
specs = T.let({}, T::Hash[[String, Integer], EntrySpec])
|
|
222
|
+
|
|
223
|
+
document.definitions.each do |definition|
|
|
224
|
+
next unless definition.is_a?(GraphQL::Language::Nodes::OperationDefinition)
|
|
225
|
+
|
|
226
|
+
spec = extract_spec(definition)
|
|
227
|
+
next if spec.nil?
|
|
228
|
+
|
|
229
|
+
# First in input order wins (spec 2.2).
|
|
230
|
+
existing = specs[spec.identity]
|
|
231
|
+
if existing
|
|
232
|
+
warn_on_conflict(existing, spec)
|
|
233
|
+
next
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
specs[spec.identity] = spec
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
specs.values
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# One spec, or `nil` for anything that is not a typed entry operation.
|
|
243
|
+
# Failing a condition is silent, not an error (spec 2.1).
|
|
244
|
+
sig do
|
|
245
|
+
params(operation: GraphQL::Language::Nodes::OperationDefinition)
|
|
246
|
+
.returns(T.nilable(EntrySpec))
|
|
247
|
+
end
|
|
248
|
+
def extract_spec(operation)
|
|
249
|
+
name = operation.name
|
|
250
|
+
return nil if name.nil?
|
|
251
|
+
|
|
252
|
+
entry = entry_argument(operation)
|
|
253
|
+
return nil if entry.nil?
|
|
254
|
+
|
|
255
|
+
# A literal `type` is what makes an operation entry-type-specific; a
|
|
256
|
+
# variable one leaves nothing to key a payload on.
|
|
257
|
+
entry_type = GraphqlAst.object_field(entry, 'type')
|
|
258
|
+
return nil unless entry_type.is_a?(String)
|
|
259
|
+
|
|
260
|
+
version = GraphqlAst.object_field(entry, 'typeVersion')
|
|
261
|
+
|
|
262
|
+
EntrySpec.new(
|
|
263
|
+
entry_type: entry_type,
|
|
264
|
+
type_version: version.is_a?(Integer) ? version : DEFAULT_TYPE_VERSION,
|
|
265
|
+
operation_name: name,
|
|
266
|
+
parameters: extract_parameters(GraphqlAst.object_field(entry, 'parameters'), operation)
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The inline `entry:` object of a single-field `addLedgerEntry` mutation, or
|
|
271
|
+
# `nil` for anything that fails a condition of spec 2.1.
|
|
272
|
+
sig do
|
|
273
|
+
params(operation: GraphQL::Language::Nodes::OperationDefinition)
|
|
274
|
+
.returns(T.nilable(GraphQL::Language::Nodes::InputObject))
|
|
275
|
+
end
|
|
276
|
+
def entry_argument(operation)
|
|
277
|
+
root = GraphqlAst.single_root_field(operation, ADD_LEDGER_ENTRY_FIELD)
|
|
278
|
+
root && GraphqlAst.inline_object_argument(root, 'entry')
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The typed parameters bound to the entry's `parameters` object (spec 2.3).
|
|
282
|
+
# Empty when `parameters` is absent or passed as a variable.
|
|
283
|
+
sig do
|
|
284
|
+
params(node: T.untyped, operation: GraphQL::Language::Nodes::OperationDefinition)
|
|
285
|
+
.returns(T::Array[Parameter])
|
|
286
|
+
end
|
|
287
|
+
def extract_parameters(node, operation)
|
|
288
|
+
return [] unless node.is_a?(GraphQL::Language::Nodes::InputObject)
|
|
289
|
+
|
|
290
|
+
types = GraphqlAst.variable_types(operation)
|
|
291
|
+
taken = T.let({}, T::Hash[Symbol, String])
|
|
292
|
+
|
|
293
|
+
node.arguments.filter_map do |argument|
|
|
294
|
+
value = argument.value
|
|
295
|
+
# A parameter the operation hardcodes is fixed by it, not caller-supplied.
|
|
296
|
+
next unless value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
|
|
297
|
+
|
|
298
|
+
type = types[value.name]
|
|
299
|
+
if type.nil?
|
|
300
|
+
logger.warn(
|
|
301
|
+
"Fragment: parameter #{argument.name.inspect} in operation " \
|
|
302
|
+
"#{operation.name} is bound to undeclared variable $#{value.name}; " \
|
|
303
|
+
'treating it as an optional untyped parameter.'
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
Parameter.new(
|
|
308
|
+
wire_name: argument.name,
|
|
309
|
+
name: local_name(argument.name, taken, operation),
|
|
310
|
+
graphql_type: type&.to_query_string || 'JSON',
|
|
311
|
+
required: type.is_a?(GraphQL::Language::Nodes::NonNullType)
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# --- Naming (spec 2.5) -------------------------------------------------
|
|
317
|
+
|
|
318
|
+
# The keyword argument and reader name for a parameter.
|
|
319
|
+
#
|
|
320
|
+
# Verbatim, so `user_id` and `userId` stay distinct. Escaped with a trailing
|
|
321
|
+
# underscore only when the name is already taken -- by an inherited method,
|
|
322
|
+
# a common field, or an earlier parameter (spec 2.5). Mutates `taken`.
|
|
323
|
+
sig do
|
|
324
|
+
params(wire_name: String, taken: T::Hash[Symbol, String],
|
|
325
|
+
operation: GraphQL::Language::Nodes::OperationDefinition)
|
|
326
|
+
.returns(Symbol)
|
|
327
|
+
end
|
|
328
|
+
def local_name(wire_name, taken, operation)
|
|
329
|
+
name = wire_name.to_sym
|
|
330
|
+
return claim(name, wire_name, taken) unless taken.key?(name) || reserved?(name)
|
|
331
|
+
|
|
332
|
+
candidate = :"#{wire_name}_"
|
|
333
|
+
counter = 2
|
|
334
|
+
while taken.key?(candidate) || reserved?(candidate)
|
|
335
|
+
candidate = :"#{wire_name}_#{counter}"
|
|
336
|
+
counter += 1
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
clash = taken[name]
|
|
340
|
+
reason = clash ? "already used by parameter #{clash.inspect}" : 'reserved by the payload class'
|
|
341
|
+
logger.warn(
|
|
342
|
+
"Fragment: parameter #{wire_name.inspect} of #{operation.name} cannot be exposed " \
|
|
343
|
+
"under that name (#{reason}); it is available as #{candidate.inspect} instead. " \
|
|
344
|
+
'The wire payload is unchanged.'
|
|
345
|
+
)
|
|
346
|
+
claim(candidate, wire_name, taken)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Names a payload already responds to. By reflection, so adding a method to
|
|
350
|
+
# the base class cannot silently shadow a Schema parameter.
|
|
351
|
+
sig { params(name: Symbol).returns(T::Boolean) }
|
|
352
|
+
def reserved?(name)
|
|
353
|
+
FragmentClient::TypedLedgerEntry.method_defined?(name) ||
|
|
354
|
+
FragmentClient::TypedLedgerEntry.private_method_defined?(name)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# A constant name for an entry type: `user-funds-account` -> `UserFundsAccount`.
|
|
358
|
+
sig { params(entry_type: String).returns(String) }
|
|
359
|
+
def constant_name(entry_type)
|
|
360
|
+
parts = entry_type
|
|
361
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
362
|
+
.split(/[^a-zA-Z\d]+/)
|
|
363
|
+
.reject(&:empty?)
|
|
364
|
+
name = parts.map { |part| part[0].to_s.upcase + T.must(part[1..]) }.join
|
|
365
|
+
# A constant must start with a letter: `2fa_hold` -> `Entry2faHold`.
|
|
366
|
+
name.match?(/\A[A-Z]/) ? name : "Entry#{name}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
private
|
|
370
|
+
|
|
371
|
+
sig { params(name: Symbol, wire_name: String, taken: T::Hash[Symbol, String]).returns(Symbol) }
|
|
372
|
+
def claim(name, wire_name, taken)
|
|
373
|
+
taken[name] = wire_name
|
|
374
|
+
name
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Define and register one payload class per spec. Locked, because
|
|
378
|
+
# concurrent client construction would otherwise race on `const_set`.
|
|
379
|
+
sig do
|
|
380
|
+
params(specs: T::Array[EntrySpec], namespace: Module, origin: T.nilable(String))
|
|
381
|
+
.returns(T::Array[T.class_of(FragmentClient::TypedLedgerEntry)])
|
|
382
|
+
end
|
|
383
|
+
def define(specs, namespace:, origin:)
|
|
384
|
+
lock.synchronize { register(specs, namespace, origin) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
sig do
|
|
388
|
+
params(specs: T::Array[EntrySpec], namespace: Module, origin: T.nilable(String))
|
|
389
|
+
.returns(T::Array[T.class_of(FragmentClient::TypedLedgerEntry)])
|
|
390
|
+
end
|
|
391
|
+
def register(specs, namespace, origin)
|
|
392
|
+
specs.map do |spec|
|
|
393
|
+
existing = registry[spec.identity]
|
|
394
|
+
if existing
|
|
395
|
+
warn_on_conflict(existing.spec, spec)
|
|
396
|
+
next existing
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
klass = FragmentClient::TypedLedgerEntry.build(spec, origin: origin)
|
|
400
|
+
name = unique_constant_name(spec, namespace)
|
|
401
|
+
namespace.const_set(name, klass)
|
|
402
|
+
defined_constants << [namespace, name]
|
|
403
|
+
registry[spec.identity] = klass
|
|
404
|
+
klass
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Two entry types can pascal-case alike (`auth_hold`, `authHold`); the
|
|
409
|
+
# operation name and then a counter break the tie so neither is dropped.
|
|
410
|
+
sig { params(spec: EntrySpec, namespace: Module).returns(String) }
|
|
411
|
+
def unique_constant_name(spec, namespace)
|
|
412
|
+
base = spec.class_name
|
|
413
|
+
return base unless namespace.const_defined?(base, false)
|
|
414
|
+
|
|
415
|
+
candidate = "#{base}#{constant_name(spec.operation_name)}"
|
|
416
|
+
counter = 2
|
|
417
|
+
while namespace.const_defined?(candidate, false)
|
|
418
|
+
candidate = "#{base}#{counter}"
|
|
419
|
+
counter += 1
|
|
420
|
+
end
|
|
421
|
+
logger.warn(
|
|
422
|
+
"Fragment: Ledger Entry type #{spec.entry_type.inspect} v#{spec.type_version} " \
|
|
423
|
+
"would be named #{base}, which is taken; it is available as #{candidate} instead."
|
|
424
|
+
)
|
|
425
|
+
candidate
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Silent when two operations agree; differing parameters mean the `.graphql`
|
|
429
|
+
# is stale relative to the Schema.
|
|
430
|
+
sig { params(kept: EntrySpec, dropped: EntrySpec).void }
|
|
431
|
+
def warn_on_conflict(kept, dropped)
|
|
432
|
+
return if kept.same_parameters?(dropped)
|
|
433
|
+
|
|
434
|
+
logger.warn(
|
|
435
|
+
"Fragment: operations #{kept.operation_name} and #{dropped.operation_name} both " \
|
|
436
|
+
"describe Ledger Entry type #{kept.entry_type.inspect} v#{kept.type_version} but " \
|
|
437
|
+
"declare different parameters (#{kept.parameters.map(&:wire_name).inspect} vs " \
|
|
438
|
+
"#{dropped.parameters.map(&:wire_name).inspect}). Keeping #{kept.operation_name}; " \
|
|
439
|
+
'the operation documents are probably stale relative to the Schema.'
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|