loams 0.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 +7 -0
- data/LICENSE +202 -0
- data/README.md +39 -0
- data/gen/loams-descriptor.binpb +0 -0
- data/lib/loams/client.rb +71 -0
- data/lib/loams/codec.rb +78 -0
- data/lib/loams/descriptors.rb +320 -0
- data/lib/loams/envelopes.rb +53 -0
- data/lib/loams/errors.rb +512 -0
- data/lib/loams/idempotency.rb +62 -0
- data/lib/loams/invoker.rb +218 -0
- data/lib/loams/pagination.rb +84 -0
- data/lib/loams/retry.rb +38 -0
- data/lib/loams/streams.rb +134 -0
- data/lib/loams/token_source.rb +27 -0
- data/lib/loams/transport.rb +83 -0
- data/lib/loams/version.rb +5 -0
- data/lib/loams/wire.rb +110 -0
- data/lib/loams.rb +22 -0
- metadata +82 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# The descriptor set, and every binding derived from it.
|
|
2
|
+
#
|
|
3
|
+
# Design §44 §7.3 has `protoc-gen-loams-facade` render one facade
|
|
4
|
+
# per language from the `loams.options.v1.module` and
|
|
5
|
+
# `loams.options.v1.facade` annotations, so thirteen SDKs cannot
|
|
6
|
+
# drift. This SDK has no generated code: it reads the **committed**
|
|
7
|
+
# `FileDescriptorSet` in `gen/` and derives every binding, module
|
|
8
|
+
# name and message type from it, which is the same property by a
|
|
9
|
+
# different route (D652) — a proto that adds an annotation changes
|
|
10
|
+
# the surface in the next build with nobody editing this file.
|
|
11
|
+
#
|
|
12
|
+
# ## Where the annotations come from
|
|
13
|
+
#
|
|
14
|
+
# `buf build` writes the options as **extension fields** on the
|
|
15
|
+
# `ServiceOptions` and `MethodOptions` messages, and the descriptor
|
|
16
|
+
# set carries them as unknown fields. `Google::Protobuf` parses the
|
|
17
|
+
# options into messages that keep those bytes, so `#to_proto` on an
|
|
18
|
+
# options message returns them, and `Loams::Wire` reads field 50001
|
|
19
|
+
# (the `module` annotation) and 50002 (the `facade` annotation) out
|
|
20
|
+
# of the raw bytes. The annotations are read from the same bytes
|
|
21
|
+
# protoc wrote, through the same descriptors, with no generated code
|
|
22
|
+
# and no registry to keep in step.
|
|
23
|
+
#
|
|
24
|
+
# ## One load, read once
|
|
25
|
+
#
|
|
26
|
+
# The set is parsed once and the resulting descriptors are immutable,
|
|
27
|
+
# so the tables are constants rather than something rebuilt per call.
|
|
28
|
+
require "google/protobuf"
|
|
29
|
+
require "google/protobuf/descriptor_pb"
|
|
30
|
+
require "loams/wire"
|
|
31
|
+
|
|
32
|
+
module Loams
|
|
33
|
+
module Descriptors
|
|
34
|
+
# The resource the descriptor set is read from, copied into the
|
|
35
|
+
# gem by the build.
|
|
36
|
+
DESCRIPTOR_SET = File.expand_path("../../gen/loams-descriptor.binpb", __dir__)
|
|
37
|
+
|
|
38
|
+
# The proto revision this SDK was generated from (design §44 §10.3).
|
|
39
|
+
PROTO_REV = "v1"
|
|
40
|
+
|
|
41
|
+
# The field number the `module` extension occupies on `ServiceOptions`.
|
|
42
|
+
MODULE_EXTENSION = 50001
|
|
43
|
+
|
|
44
|
+
# The field number the `facade` extension occupies on `MethodOptions`.
|
|
45
|
+
FACADE_EXTENSION = 50002
|
|
46
|
+
|
|
47
|
+
# The parsed set, as a `Google::Protobuf::FileDescriptorSet`.
|
|
48
|
+
SET = Google::Protobuf::FileDescriptorSet.decode(File.read(DESCRIPTOR_SET))
|
|
49
|
+
|
|
50
|
+
# The pool every message is looked up in. Built in dependency
|
|
51
|
+
# order: `add_serialized_file` links a file against the files
|
|
52
|
+
# already in the pool, so a file whose imports are not yet added
|
|
53
|
+
# is retried on a later pass, and a pass that makes no progress
|
|
54
|
+
# is a missing import rather than an ordering accident.
|
|
55
|
+
POOL = begin
|
|
56
|
+
pool = Google::Protobuf::DescriptorPool.new
|
|
57
|
+
pending = SET.file.to_a
|
|
58
|
+
until pending.empty?
|
|
59
|
+
progressed = false
|
|
60
|
+
pending.reject! do |file|
|
|
61
|
+
ready = file.dependency.all? { |dep| pool.lookup(dep) rescue false }
|
|
62
|
+
if ready
|
|
63
|
+
pool.add_serialized_file(file.to_proto)
|
|
64
|
+
progressed = true
|
|
65
|
+
true
|
|
66
|
+
else
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
raise "the descriptor set has files whose imports are absent: #{pending.map(&:name).join(", ")}" unless progressed
|
|
71
|
+
end
|
|
72
|
+
pool
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Recursively collect every message in a file into MESSAGES,
|
|
76
|
+
# keyed by fully qualified name.
|
|
77
|
+
def self.collect_messages(messages, package, prefix)
|
|
78
|
+
messages.each do |msg|
|
|
79
|
+
full = prefix.empty? ? "#{package}.#{msg.name}" : "#{prefix}.#{msg.name}"
|
|
80
|
+
MESSAGES[full] = POOL.lookup(full)
|
|
81
|
+
collect_messages(msg.nested_type.to_a, package, full)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every message in the set, keyed by its fully qualified proto name.
|
|
86
|
+
MESSAGES = {}
|
|
87
|
+
SET.file.each do |file|
|
|
88
|
+
collect_messages(file.message_type, file.package, "")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Every service in the set, in the order the files declare them.
|
|
92
|
+
SERVICES = SET.file.flat_map do |file|
|
|
93
|
+
file.service.to_a.map do |raw_svc|
|
|
94
|
+
fqdn = "#{file.package}.#{raw_svc.name}"
|
|
95
|
+
POOL.lookup(fqdn)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Every service in the set, keyed by its fully qualified name.
|
|
100
|
+
SERVICES_BY_NAME = SERVICES.to_h do |svc|
|
|
101
|
+
[svc.name, svc]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# One RPC, as the runtime dispatches it: everything a module
|
|
105
|
+
# method hands the invoker and nothing the invoker has to guess.
|
|
106
|
+
#
|
|
107
|
+
# Every field is derived from the descriptor set.
|
|
108
|
+
CallBinding = Struct.new(
|
|
109
|
+
:rpc, :service, :method, :package_name, :streaming, :idempotency,
|
|
110
|
+
:retry, :request, :response, :module, :facade_name, :summary,
|
|
111
|
+
:unstable, :pagination,
|
|
112
|
+
keyword_init: true,
|
|
113
|
+
) do
|
|
114
|
+
# Whether the call answers with one message or with a stream.
|
|
115
|
+
def server_streaming?
|
|
116
|
+
streaming == :server
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether the request's **schema** declares a string
|
|
120
|
+
# `idempotency_key`, which is what makes a mutation keyed (D610).
|
|
121
|
+
def takes_idempotency_key?
|
|
122
|
+
field = request.lookup("idempotency_key")
|
|
123
|
+
!field.nil? && field.type == :string
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The two fields a paged call pages on, as
|
|
127
|
+
# `FacadeOptions.pagination` names them (`"<items>:<next_page_token>"`),
|
|
128
|
+
# or nil.
|
|
129
|
+
def pagination_fields
|
|
130
|
+
spec = pagination
|
|
131
|
+
return nil if spec.nil? || spec.empty?
|
|
132
|
+
parts = spec.split(":")
|
|
133
|
+
return nil unless parts.size == 2
|
|
134
|
+
items = response.lookup(parts[0]) || response.find { |f| f.json_name == parts[0] }
|
|
135
|
+
token = response.lookup(parts[1]) || response.find { |f| f.json_name == parts[1] }
|
|
136
|
+
return nil if items.nil? || token.nil?
|
|
137
|
+
[items, token]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One module and the calls it exposes, both derived from the
|
|
142
|
+
# descriptor set.
|
|
143
|
+
ModuleBinding = Struct.new(
|
|
144
|
+
:name, :summary, :package_name, :service, :unstable, :calls,
|
|
145
|
+
keyword_init: true,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# `camelCase` or `PascalCase` to `snake_case`, which is what the
|
|
149
|
+
# facade's method names are.
|
|
150
|
+
def self.snake(name)
|
|
151
|
+
out = +""
|
|
152
|
+
name.each_char.with_index do |c, i|
|
|
153
|
+
if c.match?(/[A-Z]/)
|
|
154
|
+
out << "_" if i.positive? && (out[-1] != "_")
|
|
155
|
+
out << c.downcase
|
|
156
|
+
else
|
|
157
|
+
out << c
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
out
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The `loams.options.v1.ModuleOptions` on a service, read out of
|
|
164
|
+
# the raw `ServiceOptions` bytes: `{name, summary, unstable}`.
|
|
165
|
+
def self.parse_module_options(raw)
|
|
166
|
+
payload = Wire.find_bytes(raw, MODULE_EXTENSION)
|
|
167
|
+
return {} if payload.nil?
|
|
168
|
+
{
|
|
169
|
+
name: Wire.find_bytes(payload, 1),
|
|
170
|
+
summary: Wire.find_bytes(payload, 2),
|
|
171
|
+
unstable: !Wire.find_varint(payload, 3).nil? && Wire.find_varint(payload, 3) != 0,
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The `loams.options.v1.FacadeOptions` on a method, read out of
|
|
176
|
+
# the raw `MethodOptions` bytes: `{name, module, retry_safe, pagination}`.
|
|
177
|
+
def self.parse_facade_options(raw)
|
|
178
|
+
payload = Wire.find_bytes(raw, FACADE_EXTENSION)
|
|
179
|
+
return {} if payload.nil?
|
|
180
|
+
{
|
|
181
|
+
module: Wire.find_bytes(payload, 1),
|
|
182
|
+
name: Wire.find_bytes(payload, 2),
|
|
183
|
+
retry_safe: !Wire.find_varint(payload, 3).nil? && Wire.find_varint(payload, 3) != 0,
|
|
184
|
+
pagination: Wire.find_bytes(payload, 4),
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Builds one binding from a service and a method, deciding every
|
|
189
|
+
# field from the descriptor rather than from a table.
|
|
190
|
+
def self.binding_for(svc, method)
|
|
191
|
+
fd = svc.file_descriptor
|
|
192
|
+
package = fd.to_proto.package
|
|
193
|
+
service_full = svc.name
|
|
194
|
+
|
|
195
|
+
svc_opts = svc.to_proto.options
|
|
196
|
+
module_opts = svc_opts ? parse_module_options(svc_opts.to_proto) : {}
|
|
197
|
+
|
|
198
|
+
method_opts = method.to_proto.options
|
|
199
|
+
facade_opts = method_opts ? parse_facade_options(method_opts.to_proto) : {}
|
|
200
|
+
|
|
201
|
+
idempotency = if method_opts
|
|
202
|
+
case method_opts.idempotency_level
|
|
203
|
+
when :IDEMPOTENT then :idempotent
|
|
204
|
+
when :NO_SIDE_EFFECTS then :no_side_effects
|
|
205
|
+
else :none
|
|
206
|
+
end
|
|
207
|
+
else
|
|
208
|
+
:none
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
retry_class = if facade_opts[:retry_safe]
|
|
212
|
+
:safe
|
|
213
|
+
elsif idempotency == :none
|
|
214
|
+
:manual
|
|
215
|
+
else
|
|
216
|
+
:safe
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
mod = facade_opts[:module]
|
|
220
|
+
mod = mod.nil? || mod.empty? ? module_opts[:name] : mod
|
|
221
|
+
facade_name = facade_opts[:name]
|
|
222
|
+
facade_name = facade_name.nil? || facade_name.empty? ? snake(method.name) : facade_name
|
|
223
|
+
|
|
224
|
+
request = method.input_type
|
|
225
|
+
response = method.output_type
|
|
226
|
+
|
|
227
|
+
CallBinding.new(
|
|
228
|
+
rpc: "#{service_full}/#{method.name}",
|
|
229
|
+
service: service_full,
|
|
230
|
+
method: method.name,
|
|
231
|
+
package_name: package,
|
|
232
|
+
streaming: method.server_streaming ? :server : :unary,
|
|
233
|
+
idempotency: idempotency,
|
|
234
|
+
retry: retry_class,
|
|
235
|
+
request: request,
|
|
236
|
+
response: response,
|
|
237
|
+
module: mod,
|
|
238
|
+
facade_name: facade_name,
|
|
239
|
+
summary: module_opts[:summary],
|
|
240
|
+
unstable: module_opts[:unstable],
|
|
241
|
+
pagination: facade_opts[:pagination],
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Every RPC in the set, keyed by `<package>.Service/Method`.
|
|
246
|
+
BINDINGS_BY_RPC = begin
|
|
247
|
+
bindings = {}
|
|
248
|
+
SERVICES.each do |svc|
|
|
249
|
+
full = svc.name
|
|
250
|
+
svc.each do |m|
|
|
251
|
+
bindings["#{full}/#{m.name}"] = binding_for(svc, m)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
bindings
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The binding an RPC path names, or nil when no service in the set
|
|
258
|
+
# declares it.
|
|
259
|
+
def self.binding_for_rpc(rpc)
|
|
260
|
+
BINDINGS_BY_RPC[rpc]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The message a fully qualified proto name names, or nil.
|
|
264
|
+
def self.message(full_name)
|
|
265
|
+
MESSAGES[full_name]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The service a fully qualified name names, or nil.
|
|
269
|
+
def self.service(full_name)
|
|
270
|
+
SERVICES_BY_NAME[full_name]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The binding whose request or response type is +full_name+, or nil.
|
|
274
|
+
def self.binding_for_message(full_name)
|
|
275
|
+
BINDINGS_BY_RPC.values.find do |b|
|
|
276
|
+
b.request.name == full_name || b.response.name == full_name
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Every proto package in the set, as `GetInstance` names them.
|
|
281
|
+
def self.proto_packages
|
|
282
|
+
SET.file.map(&:package).select { |p| p.start_with?("loams.") }.uniq.sort
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The module catalogue, derived from the descriptor set and nothing else.
|
|
286
|
+
def self.modules
|
|
287
|
+
by_name = {}
|
|
288
|
+
BINDINGS_BY_RPC.values.each do |binding|
|
|
289
|
+
mod = binding.module
|
|
290
|
+
next if mod.nil? || mod.empty?
|
|
291
|
+
existing = by_name[mod]
|
|
292
|
+
if existing.nil?
|
|
293
|
+
by_name[mod] = ModuleBinding.new(
|
|
294
|
+
name: mod,
|
|
295
|
+
summary: binding.summary || "",
|
|
296
|
+
package_name: binding.package_name,
|
|
297
|
+
service: binding.service,
|
|
298
|
+
unstable: binding.unstable,
|
|
299
|
+
calls: [binding],
|
|
300
|
+
)
|
|
301
|
+
else
|
|
302
|
+
existing.calls << binding
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
by_name.values.map { |m| m.calls.sort_by!(&:facade_name); m }.sort_by(&:name)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The module a name identifies, or nil.
|
|
309
|
+
def self.find_module(name)
|
|
310
|
+
modules.find { |m| m.name == name }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The binding a module and a call name identify, or nil.
|
|
314
|
+
def self.find_binding(module_name, call_name)
|
|
315
|
+
mod = find_module(module_name)
|
|
316
|
+
return nil if mod.nil?
|
|
317
|
+
mod.calls.find { |c| c.facade_name == call_name }
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Loams
|
|
4
|
+
module Envelopes
|
|
5
|
+
FLAG_DATA = 0x00
|
|
6
|
+
FLAG_END_STREAM = 0x02
|
|
7
|
+
FLAG_GRPC_TRAILERS = 0x80
|
|
8
|
+
|
|
9
|
+
Frame = Struct.new(:flags, :payload) do
|
|
10
|
+
def data?
|
|
11
|
+
flags == FLAG_DATA
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def end_stream?
|
|
15
|
+
(flags & FLAG_END_STREAM) != 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def trailers?
|
|
19
|
+
(flags & FLAG_GRPC_TRAILERS) != 0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Wraps payload into 5-byte envelope frame (1 byte flags, 4 bytes big-endian length)
|
|
24
|
+
def self.wrap(payload, flags = FLAG_DATA)
|
|
25
|
+
payload = payload.b
|
|
26
|
+
[flags, payload.bytesize].pack("CN") + payload
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Splits a byte buffer containing one or more frames
|
|
30
|
+
def self.split(bytes)
|
|
31
|
+
return [] if bytes.nil? || bytes.empty?
|
|
32
|
+
|
|
33
|
+
bytes = bytes.b
|
|
34
|
+
frames = []
|
|
35
|
+
pos = 0
|
|
36
|
+
len = bytes.bytesize
|
|
37
|
+
|
|
38
|
+
while pos + 5 <= len
|
|
39
|
+
flags, payload_len = bytes.byteslice(pos, 5).unpack("CN")
|
|
40
|
+
pos += 5
|
|
41
|
+
if pos + payload_len > len
|
|
42
|
+
raise ArgumentError, "Frame length #{payload_len} exceeds remaining bytes #{len - pos}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
payload = bytes.byteslice(pos, payload_len)
|
|
46
|
+
pos += payload_len
|
|
47
|
+
frames << Frame.new(flags, payload)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
frames
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|