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.
@@ -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