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,281 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
class FragmentClient
|
|
7
|
+
# Base class for a typed `addLedgerEntries` payload. Abstract: one subclass per
|
|
8
|
+
# `(entry type, typeVersion)` is built by {FragmentClient::TypedEntries.load}.
|
|
9
|
+
#
|
|
10
|
+
# A subclass declares one keyword argument and one reader per Schema parameter,
|
|
11
|
+
# alongside the common `LedgerEntryInput` fields, and {#to_entry_input} reshapes
|
|
12
|
+
# them into the nested `AddLedgerEntryInput` the API takes.
|
|
13
|
+
#
|
|
14
|
+
# entry = FragmentClient::Entries::AuthCaptureV1.new(
|
|
15
|
+
# ik: 'ik-1', ledger_ik: 'prod', capture_amount: '100'
|
|
16
|
+
# )
|
|
17
|
+
# client.add_ledger_entries(entries: [entry])
|
|
18
|
+
#
|
|
19
|
+
# Section references are to `typed-batch-entries.md`; see
|
|
20
|
+
# `docs/spec-conformance.md`.
|
|
21
|
+
class TypedLedgerEntry
|
|
22
|
+
extend T::Sig
|
|
23
|
+
extend T::Helpers
|
|
24
|
+
abstract!
|
|
25
|
+
|
|
26
|
+
# The `LedgerEntryInput` fields every payload carries, whatever its entry type
|
|
27
|
+
# (spec 2.3a). Fixed by `LedgerEntryInput`, not derived from the operation,
|
|
28
|
+
# which binds only what its CLI version chose to expose.
|
|
29
|
+
#
|
|
30
|
+
# `lines` is absent deliberately: it cannot be combined with an entry that has
|
|
31
|
+
# a `type`. `type`, `typeVersion` and `parameters` are derived, never supplied.
|
|
32
|
+
COMMON_FIELDS = T.let(
|
|
33
|
+
%i[ik ledger_ik posted description tags groups conditions].freeze,
|
|
34
|
+
T::Array[Symbol]
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
extend T::Sig
|
|
39
|
+
|
|
40
|
+
# Build a payload class for one derived spec.
|
|
41
|
+
sig do
|
|
42
|
+
params(spec: FragmentClient::TypedEntries::EntrySpec, origin: T.nilable(String))
|
|
43
|
+
.returns(T.class_of(TypedLedgerEntry))
|
|
44
|
+
end
|
|
45
|
+
def build(spec, origin: nil)
|
|
46
|
+
klass = Class.new(self)
|
|
47
|
+
klass.instance_variable_set(:@spec, spec)
|
|
48
|
+
klass.instance_variable_set(:@source_path, origin)
|
|
49
|
+
|
|
50
|
+
spec.parameters.each do |parameter|
|
|
51
|
+
name = parameter.name
|
|
52
|
+
klass.send(:define_method, name) do
|
|
53
|
+
T.bind(self, TypedLedgerEntry)
|
|
54
|
+
parameter_value(name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
klass
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# What this payload was derived from.
|
|
62
|
+
sig { returns(FragmentClient::TypedEntries::EntrySpec) }
|
|
63
|
+
def spec
|
|
64
|
+
@spec = T.let(@spec, T.nilable(FragmentClient::TypedEntries::EntrySpec))
|
|
65
|
+
@spec || raise(
|
|
66
|
+
NotImplementedError,
|
|
67
|
+
"#{self} has no derived spec. Typed payload classes are built by " \
|
|
68
|
+
'FragmentClient::TypedEntries.load, not subclassed by hand.'
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { returns(String) }
|
|
73
|
+
def entry_type
|
|
74
|
+
spec.entry_type
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { returns(Integer) }
|
|
78
|
+
def type_version
|
|
79
|
+
spec.type_version
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The Schema parameters, in source order (spec 2.4).
|
|
83
|
+
sig { returns(T::Array[FragmentClient::TypedEntries::Parameter]) }
|
|
84
|
+
def parameters
|
|
85
|
+
spec.parameters
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The `.graphql` file this came from, if it came from one.
|
|
89
|
+
sig { returns(T.nilable(String)) }
|
|
90
|
+
def source_path
|
|
91
|
+
@source_path = T.let(@source_path, T.nilable(String))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sig { returns(String) }
|
|
96
|
+
attr_reader :ik
|
|
97
|
+
|
|
98
|
+
sig { returns(String) }
|
|
99
|
+
attr_reader :ledger_ik
|
|
100
|
+
|
|
101
|
+
# The optional common fields: `nil` when unset, so `&.` and truthiness behave
|
|
102
|
+
# as usual. {#set?} is what separates "not set" from "set to nil".
|
|
103
|
+
sig { returns(T.nilable(String)) }
|
|
104
|
+
attr_reader :posted
|
|
105
|
+
|
|
106
|
+
sig { returns(T.nilable(String)) }
|
|
107
|
+
attr_reader :description
|
|
108
|
+
|
|
109
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
110
|
+
attr_reader :tags
|
|
111
|
+
|
|
112
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
113
|
+
attr_reader :groups
|
|
114
|
+
|
|
115
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
116
|
+
attr_reader :conditions
|
|
117
|
+
|
|
118
|
+
# Optional fields default to the {FragmentClient::TypedEntries::UNSET} sentinel
|
|
119
|
+
# rather than `nil`, so an omitted keyword can be told from an explicit `nil`
|
|
120
|
+
# and left out of the payload (spec 3.2). It is converted to `nil` here and
|
|
121
|
+
# never returned.
|
|
122
|
+
#
|
|
123
|
+
# Schema parameters arrive through `**parameters` under the names this class
|
|
124
|
+
# declares. Presence and unknown names are checked here; their types come from
|
|
125
|
+
# the RBI `tapioca dsl` generates.
|
|
126
|
+
sig do
|
|
127
|
+
params(
|
|
128
|
+
ik: String,
|
|
129
|
+
ledger_ik: String,
|
|
130
|
+
posted: T.any(String, NilClass, FragmentClient::TypedEntries::Unset),
|
|
131
|
+
description: T.any(String, NilClass, FragmentClient::TypedEntries::Unset),
|
|
132
|
+
tags: T.any(T::Array[T.untyped], NilClass, FragmentClient::TypedEntries::Unset),
|
|
133
|
+
groups: T.any(T::Array[T.untyped], NilClass, FragmentClient::TypedEntries::Unset),
|
|
134
|
+
conditions: T.any(T::Array[T.untyped], NilClass, FragmentClient::TypedEntries::Unset),
|
|
135
|
+
parameters: T.untyped
|
|
136
|
+
).void
|
|
137
|
+
end
|
|
138
|
+
# rubocop:disable Metrics/AbcSize -- one assignment per common field; `typed:
|
|
139
|
+
# strict` rules out setting them in a loop.
|
|
140
|
+
def initialize(ik:, ledger_ik:,
|
|
141
|
+
posted: FragmentClient::TypedEntries::UNSET,
|
|
142
|
+
description: FragmentClient::TypedEntries::UNSET,
|
|
143
|
+
tags: FragmentClient::TypedEntries::UNSET,
|
|
144
|
+
groups: FragmentClient::TypedEntries::UNSET,
|
|
145
|
+
conditions: FragmentClient::TypedEntries::UNSET,
|
|
146
|
+
**parameters)
|
|
147
|
+
@ik = ik
|
|
148
|
+
@ledger_ik = ledger_ik
|
|
149
|
+
@parameters = T.let(validate(parameters), T::Hash[Symbol, T.untyped])
|
|
150
|
+
@provided = T.let(Set.new(@parameters.keys + %i[ik ledger_ik]), T::Set[Symbol])
|
|
151
|
+
@posted = T.let(record(:posted, posted), T.nilable(String))
|
|
152
|
+
@description = T.let(record(:description, description), T.nilable(String))
|
|
153
|
+
@tags = T.let(record(:tags, tags), T.nilable(T::Array[T.untyped]))
|
|
154
|
+
@groups = T.let(record(:groups, groups), T.nilable(T::Array[T.untyped]))
|
|
155
|
+
@conditions = T.let(record(:conditions, conditions), T.nilable(T::Array[T.untyped]))
|
|
156
|
+
end
|
|
157
|
+
# rubocop:enable Metrics/AbcSize
|
|
158
|
+
|
|
159
|
+
# Whether the caller set `name` -- a common field or a parameter. The readers
|
|
160
|
+
# report an omitted field and an explicit `nil` alike, so this is the only way
|
|
161
|
+
# to tell them apart.
|
|
162
|
+
sig { params(name: Symbol).returns(T::Boolean) }
|
|
163
|
+
def set?(name)
|
|
164
|
+
@provided.include?(name)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Same class, same wire payload -- which includes agreeing on what is set.
|
|
168
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
169
|
+
def ==(other)
|
|
170
|
+
other.instance_of?(self.class) && other.to_entry_input == to_entry_input
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
alias eql? ==
|
|
174
|
+
|
|
175
|
+
sig { returns(Integer) }
|
|
176
|
+
def hash
|
|
177
|
+
[self.class, to_entry_input].hash
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The `AddLedgerEntryInput` this payload posts (spec 3.1). Keys lexicographic
|
|
181
|
+
# at every level except `parameters`, which keeps source order (spec 3.4).
|
|
182
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
183
|
+
def to_entry_input
|
|
184
|
+
{ 'entry' => entry_input, 'ik' => @ik }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
alias to_h to_entry_input
|
|
188
|
+
|
|
189
|
+
# The `parameters` payload, keyed by verbatim Schema parameter name -- so an
|
|
190
|
+
# escaped parameter still travels under its own key (spec 2.5, 3.3).
|
|
191
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
192
|
+
def entry_parameters
|
|
193
|
+
self.class.parameters.each_with_object({}) do |parameter, out|
|
|
194
|
+
out[parameter.wire_name] = @parameters[parameter.name] if @parameters.key?(parameter.name)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
sig { returns(String) }
|
|
199
|
+
def inspect
|
|
200
|
+
shown = COMMON_FIELDS.select { |name| set?(name) }
|
|
201
|
+
.map { |name| "#{name}: #{public_send(name).inspect}" }
|
|
202
|
+
shown.concat(@parameters.map { |name, value| "#{name}: #{value.inspect}" })
|
|
203
|
+
"#<#{self.class} #{shown.join(', ')}>"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Records that `name` was supplied, and unwraps the sentinel to `nil`.
|
|
209
|
+
sig { params(name: Symbol, value: T.untyped).returns(T.untyped) }
|
|
210
|
+
def record(name, value)
|
|
211
|
+
return nil if value.is_a?(FragmentClient::TypedEntries::Unset)
|
|
212
|
+
|
|
213
|
+
@provided << name
|
|
214
|
+
value
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
sig { params(name: Symbol).returns(T.untyped) }
|
|
218
|
+
def parameter_value(name)
|
|
219
|
+
@parameters[name]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
223
|
+
def entry_input
|
|
224
|
+
input = T.let({}, T::Hash[String, T.untyped])
|
|
225
|
+
input['conditions'] = @conditions if set?(:conditions)
|
|
226
|
+
input['description'] = @description if set?(:description)
|
|
227
|
+
input['groups'] = @groups if set?(:groups)
|
|
228
|
+
input['ledger'] = { 'ik' => @ledger_ik }
|
|
229
|
+
# Always present: derived rather than supplied, so it has no unset state.
|
|
230
|
+
input['parameters'] = entry_parameters
|
|
231
|
+
input['posted'] = @posted if set?(:posted)
|
|
232
|
+
input['tags'] = @tags if set?(:tags)
|
|
233
|
+
input['type'] = self.class.entry_type
|
|
234
|
+
input['typeVersion'] = self.class.type_version
|
|
235
|
+
input
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
sig { params(supplied: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
239
|
+
def validate(supplied)
|
|
240
|
+
reject_unknown(supplied)
|
|
241
|
+
require_declared(supplied)
|
|
242
|
+
supplied
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
sig { params(supplied: T::Hash[Symbol, T.untyped]).void }
|
|
246
|
+
def reject_unknown(supplied)
|
|
247
|
+
known = self.class.parameters.map(&:name)
|
|
248
|
+
unknown = supplied.keys - known
|
|
249
|
+
return if unknown.empty?
|
|
250
|
+
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"unknown keyword#{plural(unknown)}: #{list(unknown)} for #{describe}. " \
|
|
253
|
+
"Declared parameters: #{known.empty? ? '(none)' : list(known)}."
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
sig { params(supplied: T::Hash[Symbol, T.untyped]).void }
|
|
257
|
+
def require_declared(supplied)
|
|
258
|
+
missing = self.class.parameters
|
|
259
|
+
.select { |parameter| parameter.required && !supplied.key?(parameter.name) }
|
|
260
|
+
.map(&:name)
|
|
261
|
+
return if missing.empty?
|
|
262
|
+
|
|
263
|
+
raise ArgumentError, "missing keyword#{plural(missing)}: #{list(missing)} for #{describe}."
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
sig { returns(String) }
|
|
267
|
+
def describe
|
|
268
|
+
"#{self.class.entry_type.inspect} v#{self.class.type_version}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
sig { params(names: T::Array[Symbol]).returns(String) }
|
|
272
|
+
def plural(names)
|
|
273
|
+
names.length == 1 ? '' : 's'
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
sig { params(names: T::Array[Symbol]).returns(String) }
|
|
277
|
+
def list(names)
|
|
278
|
+
names.map(&:inspect).join(', ')
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
data/lib/fragment_client.rb
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
# typed:
|
|
1
|
+
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'graphql/client'
|
|
6
6
|
require 'graphql/client/http'
|
|
7
|
+
require 'logger'
|
|
7
8
|
require 'sorbet-runtime'
|
|
8
9
|
require 'uri'
|
|
9
10
|
require 'net/http'
|
|
10
11
|
require 'fragment_client/version'
|
|
12
|
+
require 'fragment_client/typed_entries'
|
|
11
13
|
module GraphQL
|
|
12
14
|
module StaticValidation
|
|
15
|
+
# Accepts an inline object literal for the JSON-shaped scalars, which
|
|
16
|
+
# graphql-ruby otherwise rejects -- and which every `parameters: {...}` is.
|
|
13
17
|
class LiteralValidator
|
|
14
18
|
alias recursive_validate_old recursively_validate
|
|
15
19
|
def recursively_validate(ast_value, type)
|
|
@@ -48,6 +52,7 @@ module FragmentGraphQl
|
|
|
48
52
|
|
|
49
53
|
# Create a custom client class for Fragment-specific behavior
|
|
50
54
|
class CustomClient < GraphQL::Client
|
|
55
|
+
# Keeps a memory address out of the operation name sent to the API.
|
|
51
56
|
class Definition < GraphQL::Client::Definition
|
|
52
57
|
def definition_name
|
|
53
58
|
super.gsub(/#<Module.*>/, 'FragmentGraphQl__Dynamic')
|
|
@@ -69,6 +74,71 @@ module FragmentGraphQl
|
|
|
69
74
|
end
|
|
70
75
|
|
|
71
76
|
FragmentQueries = T.let(parse_queries("#{__dir__}/queries.graphql"), T.untyped)
|
|
77
|
+
|
|
78
|
+
# Look up one parsed operation by name.
|
|
79
|
+
#
|
|
80
|
+
# `FragmentQueries::AddLedgerEntries` reads better but does not resolve: the
|
|
81
|
+
# constants are created by `GraphQL::Client.parse` at runtime.
|
|
82
|
+
sig { params(name: Symbol).returns(T.untyped) }
|
|
83
|
+
def self.operation(name)
|
|
84
|
+
FragmentQueries.const_get(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The instance method `FragmentClient` defines for an operation:
|
|
88
|
+
# `ListLedgerAccounts` -> `list_ledger_accounts`.
|
|
89
|
+
sig { params(operation_name: String).returns(String) }
|
|
90
|
+
def self.method_name_for(operation_name)
|
|
91
|
+
operation_name.gsub(/[a-z]([A-Z])/) do |m|
|
|
92
|
+
format('%<lower>s_%<upper>s', lower: m[0], upper: T.must(m[1]).downcase)
|
|
93
|
+
end.gsub(/^[A-Z]/, &:downcase)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Method names for every operation parsed so far, in the order first seen.
|
|
97
|
+
#
|
|
98
|
+
# Read by the Tapioca compiler, which cannot otherwise see methods that
|
|
99
|
+
# `define_method_from_queries` creates per instance.
|
|
100
|
+
sig { returns(T::Array[String]) }
|
|
101
|
+
def self.operation_method_names
|
|
102
|
+
@operation_method_names ||= T.let([], T.nilable(T::Array[String]))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sig { params(queries: T.untyped).void }
|
|
106
|
+
def self.record_operations(queries)
|
|
107
|
+
queries.constants.each do |constant|
|
|
108
|
+
name = method_name_for(constant.to_s)
|
|
109
|
+
operation_method_names << name unless operation_method_names.include?(name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Operation ASTs by operation name, for the compilers that need a selection set
|
|
114
|
+
# rather than just a method name.
|
|
115
|
+
sig { returns(T::Hash[String, GraphQL::Language::Nodes::OperationDefinition]) }
|
|
116
|
+
def self.operations
|
|
117
|
+
@operations ||= T.let({}, T.nilable(T::Hash[String,
|
|
118
|
+
GraphQL::Language::Nodes::OperationDefinition]))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
sig { params(source: String).void }
|
|
122
|
+
def self.record_document(source)
|
|
123
|
+
GraphQL.parse(source).definitions.each do |definition|
|
|
124
|
+
next unless definition.is_a?(GraphQL::Language::Nodes::OperationDefinition)
|
|
125
|
+
|
|
126
|
+
name = definition.name
|
|
127
|
+
operations[name] ||= definition if name
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Forget operations recorded from anything but `queries.graphql`. For tests.
|
|
132
|
+
sig { void }
|
|
133
|
+
def self.reset_operations!
|
|
134
|
+
operation_method_names.clear
|
|
135
|
+
operations.clear
|
|
136
|
+
record_operations(FragmentQueries)
|
|
137
|
+
record_document(File.read("#{__dir__}/queries.graphql"))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
record_operations(FragmentQueries)
|
|
141
|
+
record_document(File.read("#{__dir__}/queries.graphql"))
|
|
72
142
|
end
|
|
73
143
|
|
|
74
144
|
# A client for Fragment
|
|
@@ -81,15 +151,19 @@ class FragmentClient
|
|
|
81
151
|
|
|
82
152
|
extend T::Sig
|
|
83
153
|
|
|
154
|
+
DEFAULT_OAUTH_URL = 'https://auth.fragment.dev/oauth2/token'
|
|
155
|
+
DEFAULT_OAUTH_SCOPE = 'https://api.fragment.dev/*'
|
|
156
|
+
|
|
84
157
|
sig do
|
|
85
158
|
params(client_id: String, client_secret: String, extra_queries_filenames: T.nilable(T::Array[String]),
|
|
86
159
|
api_url: T.nilable(String), oauth_url: T.nilable(String), oauth_scope: T.nilable(String)).void
|
|
87
160
|
end
|
|
88
161
|
|
|
89
162
|
def initialize(client_id, client_secret, extra_queries_filenames: nil, api_url: nil,
|
|
90
|
-
oauth_url:
|
|
91
|
-
|
|
92
|
-
@
|
|
163
|
+
oauth_url: DEFAULT_OAUTH_URL, oauth_scope: DEFAULT_OAUTH_SCOPE)
|
|
164
|
+
# Both are nilable, so nil means the default rather than an assertion failure.
|
|
165
|
+
@oauth_scope = T.let(oauth_scope || DEFAULT_OAUTH_SCOPE, String)
|
|
166
|
+
@oauth_url = T.let(parse_oauth_url(oauth_url || DEFAULT_OAUTH_URL), URI::HTTP)
|
|
93
167
|
@client_id = T.let(client_id, String)
|
|
94
168
|
@client_secret = T.let(client_secret, String)
|
|
95
169
|
|
|
@@ -108,10 +182,63 @@ class FragmentClient
|
|
|
108
182
|
define_method_from_queries(FragmentGraphQl::FragmentQueries)
|
|
109
183
|
return if extra_queries_filenames.nil?
|
|
110
184
|
|
|
111
|
-
extra_queries_filenames.each
|
|
112
|
-
|
|
113
|
-
|
|
185
|
+
extra_queries_filenames.each { |filename| define_method_from_queries(self.class.load_queries(filename)) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Parse a `.graphql` document and register what can be derived from it: the
|
|
189
|
+
# operation names {FragmentClient} will answer to, and a typed payload class per
|
|
190
|
+
# Ledger Entry type it declares.
|
|
191
|
+
#
|
|
192
|
+
# Needs neither credentials nor network, so `bundle exec tapioca dsl` can see
|
|
193
|
+
# both if this runs in an initializer. {#initialize} calls it for every
|
|
194
|
+
# `extra_queries_filenames` entry, so passing the files is usually enough.
|
|
195
|
+
sig { params(paths: String).returns(T.untyped) }
|
|
196
|
+
def self.load_queries(*paths)
|
|
197
|
+
queries = paths.map do |path|
|
|
198
|
+
source = File.read(path)
|
|
199
|
+
parsed = T.let(FragmentGraphQl.parse_queries(path), T.untyped)
|
|
200
|
+
FragmentGraphQl.record_operations(parsed)
|
|
201
|
+
FragmentGraphQl.record_document(source)
|
|
202
|
+
TypedEntries.load(path)
|
|
203
|
+
parsed
|
|
114
204
|
end
|
|
205
|
+
queries.length == 1 ? queries.first : queries
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Operations with a hand-written wrapper below, which the dynamic definer must
|
|
209
|
+
# not shadow -- a singleton method beats an instance method. Listed explicitly
|
|
210
|
+
# rather than tested with `method_defined?`, which would also match `clone`,
|
|
211
|
+
# `freeze` and everything else on Object.
|
|
212
|
+
WRAPPED_OPERATIONS = T.let(%w[add_ledger_entries].freeze, T::Array[String])
|
|
213
|
+
|
|
214
|
+
# Commit a batch of Ledger Entries atomically: every entry commits or none do,
|
|
215
|
+
# so there is no partial-batch state to reconcile.
|
|
216
|
+
#
|
|
217
|
+
#
|
|
218
|
+
# `entries` may mix typed payloads from {FragmentClient::TypedEntries} with raw
|
|
219
|
+
# `AddLedgerEntryInput` hashes, in any order; the API returns results in the
|
|
220
|
+
# order sent (spec 3.5). Idempotency keys are per entry, so `isIkReplay` is
|
|
221
|
+
# reported per result.
|
|
222
|
+
#
|
|
223
|
+
# The response is a union. Narrow on `__typename` before reading `results`, and
|
|
224
|
+
# read `errors` on `AddLedgerEntriesError` for the per-entry failures, each
|
|
225
|
+
# carrying the `ik` of the entry that failed (spec 4).
|
|
226
|
+
# Takes the operation's variables, like every other operation method, so
|
|
227
|
+
# `add_ledger_entries(entries: [...])` and `add_ledger_entries({ entries: [...] })`
|
|
228
|
+
# both work. Typed payloads in `entries` are converted; anything else passes
|
|
229
|
+
# through.
|
|
230
|
+
#
|
|
231
|
+
# The return type stays untyped here, unlike the generated methods: a signature in
|
|
232
|
+
# shipped source cannot name `FragmentClient::Responses::AddLedgerEntries`, which
|
|
233
|
+
# does not exist until a consumer runs `tapioca dsl`.
|
|
234
|
+
sig { params(variables: T::Hash[T.untyped, T.untyped]).returns(T.untyped) }
|
|
235
|
+
def add_ledger_entries(variables)
|
|
236
|
+
query(
|
|
237
|
+
FragmentGraphQl.operation(:AddLedgerEntries),
|
|
238
|
+
variables.to_h do |name, value|
|
|
239
|
+
[name, name.to_s == 'entries' ? TypedEntries.to_entry_inputs(value) : value]
|
|
240
|
+
end
|
|
241
|
+
)
|
|
115
242
|
end
|
|
116
243
|
|
|
117
244
|
# Move these error class definitions up, before the query method
|
|
@@ -130,9 +257,10 @@ class FragmentClient
|
|
|
130
257
|
|
|
131
258
|
def define_method_from_queries(queries)
|
|
132
259
|
queries.constants.each do |qry|
|
|
133
|
-
name = qry.to_s
|
|
134
|
-
|
|
135
|
-
|
|
260
|
+
name = FragmentGraphQl.method_name_for(qry.to_s)
|
|
261
|
+
|
|
262
|
+
# Leave the hand-written wrapper in place; see WRAPPED_OPERATIONS.
|
|
263
|
+
next if WRAPPED_OPERATIONS.include?(name)
|
|
136
264
|
|
|
137
265
|
# Get the original query
|
|
138
266
|
original_query = queries.const_get(qry)
|
|
@@ -149,6 +277,7 @@ class FragmentClient
|
|
|
149
277
|
|
|
150
278
|
# Define the new method that uses the stored original method
|
|
151
279
|
definition_node.singleton_class.send(:define_method, :name) do
|
|
280
|
+
T.bind(self, T.untyped)
|
|
152
281
|
original_name.gsub(/#<Module.*?>/, 'FragmentGraphQl__Dynamic__Custom')
|
|
153
282
|
end
|
|
154
283
|
end
|
|
@@ -161,17 +290,40 @@ class FragmentClient
|
|
|
161
290
|
end
|
|
162
291
|
end
|
|
163
292
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
293
|
+
# Reject an unusable `oauth_url` here rather than in {create_token}, which fails
|
|
294
|
+
# on `request_uri` for a non-HTTP URL and says nothing about the argument.
|
|
295
|
+
sig { params(oauth_url: String).returns(URI::HTTP) }
|
|
296
|
+
def parse_oauth_url(oauth_url)
|
|
297
|
+
parsed = begin
|
|
298
|
+
URI.parse(oauth_url)
|
|
299
|
+
rescue URI::InvalidURIError => e
|
|
300
|
+
raise ArgumentError, "oauth_url is not a valid URL (#{e.message}), got #{oauth_url.inspect}"
|
|
301
|
+
end
|
|
302
|
+
# URI::HTTPS subclasses URI::HTTP, so this accepts both.
|
|
303
|
+
return parsed if parsed.is_a?(URI::HTTP)
|
|
304
|
+
|
|
305
|
+
raise ArgumentError, "oauth_url must be an http or https URL, got #{oauth_url.inspect}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# RFC 6749 §4.4.2 and §2.3.1: client_credentials over
|
|
309
|
+
# application/x-www-form-urlencoded, with the client id and secret as HTTP Basic.
|
|
310
|
+
sig { returns(Net::HTTP::Post) }
|
|
311
|
+
def token_request
|
|
312
|
+
post = Net::HTTP::Post.new(@oauth_url.request_uri)
|
|
168
313
|
post.basic_auth(@client_id, @client_secret)
|
|
169
|
-
post.content_type =
|
|
314
|
+
post.content_type = 'application/x-www-form-urlencoded'
|
|
170
315
|
post.body = URI.encode_www_form(
|
|
171
316
|
grant_type: 'client_credentials',
|
|
172
317
|
scope: @oauth_scope,
|
|
173
318
|
client_id: @client_id
|
|
174
319
|
)
|
|
320
|
+
post
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
sig { returns(Token) }
|
|
324
|
+
def create_token
|
|
325
|
+
uri = @oauth_url
|
|
326
|
+
post = token_request
|
|
175
327
|
|
|
176
328
|
begin
|
|
177
329
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
@@ -197,6 +349,7 @@ class FragmentClient
|
|
|
197
349
|
end
|
|
198
350
|
end
|
|
199
351
|
|
|
352
|
+
# Process-wide settings, set with {FragmentClient.configure}.
|
|
200
353
|
class Configuration
|
|
201
354
|
extend T::Sig
|
|
202
355
|
|
|
@@ -223,7 +376,7 @@ class FragmentClient
|
|
|
223
376
|
|
|
224
377
|
sig { params(blk: T.proc.params(config: Configuration).void).void }
|
|
225
378
|
def configure(&blk)
|
|
226
|
-
|
|
379
|
+
blk.call(configuration)
|
|
227
380
|
end
|
|
228
381
|
end
|
|
229
382
|
|
data/lib/queries.graphql
CHANGED
|
@@ -94,6 +94,52 @@ mutation DeleteLedger($ledger: LedgerMatchInput!) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
mutation AddLedgerEntries($entries: [AddLedgerEntryInput!]!) {
|
|
98
|
+
addLedgerEntries(entries: $entries) {
|
|
99
|
+
__typename
|
|
100
|
+
... on AddLedgerEntriesResult {
|
|
101
|
+
results {
|
|
102
|
+
isIkReplay
|
|
103
|
+
entry {
|
|
104
|
+
type
|
|
105
|
+
id
|
|
106
|
+
ik
|
|
107
|
+
posted
|
|
108
|
+
created
|
|
109
|
+
}
|
|
110
|
+
lines {
|
|
111
|
+
id
|
|
112
|
+
amount
|
|
113
|
+
account {
|
|
114
|
+
path
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
... on AddLedgerEntriesError {
|
|
120
|
+
code
|
|
121
|
+
message
|
|
122
|
+
retryable
|
|
123
|
+
errors {
|
|
124
|
+
ik
|
|
125
|
+
code
|
|
126
|
+
message
|
|
127
|
+
retryable
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
... on BadRequestError {
|
|
131
|
+
code
|
|
132
|
+
message
|
|
133
|
+
retryable
|
|
134
|
+
}
|
|
135
|
+
... on InternalError {
|
|
136
|
+
code
|
|
137
|
+
message
|
|
138
|
+
retryable
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
mutation AddLedgerEntry(
|
|
98
144
|
$ik: SafeString!
|
|
99
145
|
$ledgerIk: SafeString!
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
return unless defined?(Tapioca::Dsl::Compilers)
|
|
5
|
+
|
|
6
|
+
require 'fragment_client'
|
|
7
|
+
|
|
8
|
+
module Tapioca
|
|
9
|
+
module Dsl
|
|
10
|
+
module Compilers
|
|
11
|
+
# Declares the query and mutation methods {FragmentClient} answers to.
|
|
12
|
+
#
|
|
13
|
+
# `define_method_from_queries` creates one singleton method per operation on
|
|
14
|
+
# each client instance, so without this Sorbet reports `Method
|
|
15
|
+
# 'create_ledger' does not exist` for every one of them and a consumer has to
|
|
16
|
+
# write `T.unsafe(client)`.
|
|
17
|
+
#
|
|
18
|
+
# Covers the operations shipped in `queries.graphql`, plus any document
|
|
19
|
+
# passed to `FragmentClient.load_queries`. Operations in
|
|
20
|
+
# {FragmentClient::WRAPPED_OPERATIONS} are skipped: those have hand-written
|
|
21
|
+
# methods with real signatures already.
|
|
22
|
+
#
|
|
23
|
+
# `variables` is the operation's variables hash and stays untyped. Its keys
|
|
24
|
+
# are the GraphQL variable names verbatim, as the rest of this SDK passes
|
|
25
|
+
# them. The return type comes from `FragmentResponseTypes`.
|
|
26
|
+
class FragmentQueryMethods < Compiler
|
|
27
|
+
extend T::Sig
|
|
28
|
+
|
|
29
|
+
ConstantType = type_member { { fixed: T.class_of(FragmentClient) } }
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
extend T::Sig
|
|
33
|
+
|
|
34
|
+
sig { override.returns(T::Enumerable[Module]) }
|
|
35
|
+
def gather_constants
|
|
36
|
+
[FragmentClient]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { override.void }
|
|
41
|
+
def decorate
|
|
42
|
+
methods = FragmentGraphQl.operations.keys.to_h do |operation|
|
|
43
|
+
[FragmentGraphQl.method_name_for(operation), operation]
|
|
44
|
+
end
|
|
45
|
+
methods.reject! { |name, _| FragmentClient::WRAPPED_OPERATIONS.include?(name) }
|
|
46
|
+
return if methods.empty?
|
|
47
|
+
|
|
48
|
+
root.create_path(constant) do |klass|
|
|
49
|
+
methods.keys.sort.each do |name|
|
|
50
|
+
klass.create_method(
|
|
51
|
+
name,
|
|
52
|
+
parameters: [create_param('variables', type: 'T::Hash[Symbol, T.untyped]')],
|
|
53
|
+
return_type: "::FragmentClient::Responses::#{methods.fetch(name)}"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|