Module: FragmentClient::TypedEntries

Extended by:
T::Sig
Defined in:
lib/fragment_client/typed_entries.rb

Overview

Derives typed addLedgerEntries payloads from the per-entry-type addLedgerEntry operations the Fragment CLI generates for a Schema.

A generated operation names the entry type as a string literal and binds each parameter to a typed variable, which is what addLedgerEntries alone cannot express: its parameters is an opaque JSON scalar.

FragmentClient::TypedEntries.load('app/graphql/entries.graphql')
entry = FragmentClient::Entries::AuthCaptureV1.new(
  ik: 'ik-1', ledger_ik: 'prod', capture_amount: '100'
)
client.add_ledger_entries(entries: [entry])

Payload classes are built at load time, so Sorbet sees them only through the RBI bundle exec tapioca dsl generates.

Implements typed-batch-entries.md from fragment-dev/graphql-queries. Section references throughout are to that spec; docs/spec-conformance.md maps it onto this SDK and explains the choices.

Defined Under Namespace

Classes: EntrySpec, Error, Parameter, UnknownEntryTypeError, Unset

Constant Summary collapse

ADD_LEDGER_ENTRY_FIELD =

The only field a typed entry operation may select (spec 2.1).

'addLedgerEntry'
DEFAULT_TYPE_VERSION =

What an entry with no typeVersion resolves to server-side (spec 2.5).

1
UNSET =
T.let(Unset.new.freeze, Unset)

Class Method Summary collapse

Class Method Details

.constant_name(entry_type) ⇒ Object



359
360
361
362
363
364
365
366
367
# File 'lib/fragment_client/typed_entries.rb', line 359

def constant_name(entry_type)
  parts = entry_type
          .gsub(/([a-z\d])([A-Z])/, '\1_\2')
          .split(/[^a-zA-Z\d]+/)
          .reject(&:empty?)
  name = parts.map { |part| part[0].to_s.upcase + T.must(part[1..]) }.join
  # A constant must start with a letter: `2fa_hold` -> `Entry2faHold`.
  name.match?(/\A[A-Z]/) ? name : "Entry#{name}"
end

.defined_constantsObject



212
213
214
# File 'lib/fragment_client/typed_entries.rb', line 212

def defined_constants
  @defined_constants ||= T.let([], T.nilable(T::Array[[Module, String]]))
end

.entry_argument(operation) ⇒ Object



276
277
278
279
# File 'lib/fragment_client/typed_entries.rb', line 276

def entry_argument(operation)
  root = GraphqlAst.single_root_field(operation, ADD_LEDGER_ENTRY_FIELD)
  root && GraphqlAst.inline_object_argument(root, 'entry')
end

.extract(document) ⇒ Object



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/fragment_client/typed_entries.rb', line 220

def extract(document)
  specs = T.let({}, T::Hash[[String, Integer], EntrySpec])

  document.definitions.each do |definition|
    next unless definition.is_a?(GraphQL::Language::Nodes::OperationDefinition)

    spec = extract_spec(definition)
    next if spec.nil?

    # First in input order wins (spec 2.2).
    existing = specs[spec.identity]
    if existing
      warn_on_conflict(existing, spec)
      next
    end

    specs[spec.identity] = spec
  end

  specs.values
end

.extract_parameters(node, operation) ⇒ Object



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/fragment_client/typed_entries.rb', line 287

def extract_parameters(node, operation)
  return [] unless node.is_a?(GraphQL::Language::Nodes::InputObject)

  types = GraphqlAst.variable_types(operation)
  taken = T.let({}, T::Hash[Symbol, String])

  node.arguments.filter_map do |argument|
    value = argument.value
    # A parameter the operation hardcodes is fixed by it, not caller-supplied.
    next unless value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)

    type = types[value.name]
    if type.nil?
      logger.warn(
        "Fragment: parameter #{argument.name.inspect} in operation " \
        "#{operation.name} is bound to undeclared variable $#{value.name}; " \
        'treating it as an optional untyped parameter.'
      )
    end

    Parameter.new(
      wire_name: argument.name,
      name: local_name(argument.name, taken, operation),
      graphql_type: type&.to_query_string || 'JSON',
      required: type.is_a?(GraphQL::Language::Nodes::NonNullType)
    )
  end
end

.extract_spec(operation) ⇒ Object



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/fragment_client/typed_entries.rb', line 248

def extract_spec(operation)
  name = operation.name
  return nil if name.nil?

  entry = entry_argument(operation)
  return nil if entry.nil?

  # A literal `type` is what makes an operation entry-type-specific; a
  # variable one leaves nothing to key a payload on.
  entry_type = GraphqlAst.object_field(entry, 'type')
  return nil unless entry_type.is_a?(String)

  version = GraphqlAst.object_field(entry, 'typeVersion')

  EntrySpec.new(
    entry_type: entry_type,
    type_version: version.is_a?(Integer) ? version : DEFAULT_TYPE_VERSION,
    operation_name: name,
    parameters: extract_parameters(GraphqlAst.object_field(entry, 'parameters'), operation)
  )
end

.fetch(entry_type, type_version = DEFAULT_TYPE_VERSION) ⇒ Object



164
165
166
167
168
169
170
171
# File 'lib/fragment_client/typed_entries.rb', line 164

def fetch(entry_type, type_version = DEFAULT_TYPE_VERSION)
  registry.fetch([entry_type, type_version]) do
    raise UnknownEntryTypeError,
          "No typed payload loaded for Ledger Entry type #{entry_type.inspect} " \
          "version #{type_version}. Pass the .graphql file declaring its " \
          'addLedgerEntry operation to FragmentClient::TypedEntries.load.'
  end
end

.load(*paths, namespace: Entries) ⇒ Object



144
145
146
# File 'lib/fragment_client/typed_entries.rb', line 144

def load(*paths, namespace: Entries)
  paths.flat_map { |path| load_string(File.read(path), namespace: namespace, origin: path) }
end

.load_string(source, namespace: Entries, origin: nil) ⇒ Object



153
154
155
# File 'lib/fragment_client/typed_entries.rb', line 153

def load_string(source, namespace: Entries, origin: nil)
  define(extract(GraphQL.parse(source)), namespace: namespace, origin: origin)
end

.local_name(wire_name, taken, operation) ⇒ Object



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/fragment_client/typed_entries.rb', line 328

def local_name(wire_name, taken, operation)
  name = wire_name.to_sym
  return claim(name, wire_name, taken) unless taken.key?(name) || reserved?(name)

  candidate = :"#{wire_name}_"
  counter = 2
  while taken.key?(candidate) || reserved?(candidate)
    candidate = :"#{wire_name}_#{counter}"
    counter += 1
  end

  clash = taken[name]
  reason = clash ? "already used by parameter #{clash.inspect}" : 'reserved by the payload class'
  logger.warn(
    "Fragment: parameter #{wire_name.inspect} of #{operation.name} cannot be exposed " \
    "under that name (#{reason}); it is available as #{candidate.inspect} instead. " \
    'The wire payload is unchanged.'
  )
  claim(candidate, wire_name, taken)
end

.lockObject



205
206
207
# File 'lib/fragment_client/typed_entries.rb', line 205

def lock
  @lock ||= T.let(Thread::Mutex.new, T.nilable(Thread::Mutex))
end

.loggerObject



200
201
202
# File 'lib/fragment_client/typed_entries.rb', line 200

def logger
  FragmentClient.configuration.logger
end

.registryObject



175
176
177
178
# File 'lib/fragment_client/typed_entries.rb', line 175

def registry
  @registry ||= T.let({}, T.nilable(T::Hash[[String, Integer],
                                            T.class_of(FragmentClient::TypedLedgerEntry)]))
end

.reserved?(name) ⇒ Boolean

Returns:

  • (Boolean)


352
353
354
355
# File 'lib/fragment_client/typed_entries.rb', line 352

def reserved?(name)
  FragmentClient::TypedLedgerEntry.method_defined?(name) ||
    FragmentClient::TypedLedgerEntry.private_method_defined?(name)
end

.reset!Object



182
183
184
185
186
187
188
# File 'lib/fragment_client/typed_entries.rb', line 182

def reset!
  defined_constants.each do |namespace, name|
    namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
  end
  defined_constants.clear
  registry.clear
end

.to_entry_inputs(entries) ⇒ Object



193
194
195
196
197
# File 'lib/fragment_client/typed_entries.rb', line 193

def to_entry_inputs(entries)
  entries.map do |entry|
    entry.is_a?(FragmentClient::TypedLedgerEntry) ? entry.to_entry_input : entry
  end
end