orbitcast 0.3.1-x86_64-linux-gnu
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.txt +28 -0
- data/README.md +100 -0
- data/app/assets/javascripts/orbitcast/recovery.js +196 -0
- data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
- data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
- data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
- data/exe/orbitcast +11 -0
- data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
- data/lib/generators/orbitcast/install/install_generator.rb +223 -0
- data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
- data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
- data/lib/orbitcast/binary.rb +234 -0
- data/lib/orbitcast/channel.rb +459 -0
- data/lib/orbitcast/channel_registry.rb +131 -0
- data/lib/orbitcast/configuration.rb +636 -0
- data/lib/orbitcast/connection.rb +338 -0
- data/lib/orbitcast/control.rb +72 -0
- data/lib/orbitcast/dispatcher.rb +306 -0
- data/lib/orbitcast/engine.rb +98 -0
- data/lib/orbitcast/error.rb +54 -0
- data/lib/orbitcast/launcher.rb +302 -0
- data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
- data/lib/orbitcast/protocol/v1/effects.rb +172 -0
- data/lib/orbitcast/protocol/v1/reply.rb +268 -0
- data/lib/orbitcast/protocol/v1/request.rb +364 -0
- data/lib/orbitcast/protocol/v1.rb +291 -0
- data/lib/orbitcast/protocol.rb +19 -0
- data/lib/orbitcast/publisher/control.rb +67 -0
- data/lib/orbitcast/publisher/http.rb +180 -0
- data/lib/orbitcast/publisher/instrumentation.rb +47 -0
- data/lib/orbitcast/publisher/postgres.rb +407 -0
- data/lib/orbitcast/rails_endpoint.rb +207 -0
- data/lib/orbitcast/recovery.rb +166 -0
- data/lib/orbitcast/state_codec.rb +186 -0
- data/lib/orbitcast/streams.rb +129 -0
- data/lib/orbitcast/version.rb +10 -0
- data/lib/orbitcast.rb +68 -0
- data/vendor/x86_64-linux-gnu/bin/orbitcast +0 -0
- data/vendor/x86_64-linux-gnu/manifest.json +14 -0
- metadata +130 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../v1"
|
|
4
|
+
|
|
5
|
+
module Orbitcast
|
|
6
|
+
module Protocol
|
|
7
|
+
module V1
|
|
8
|
+
# Rails' answer to one request, built fail-closed.
|
|
9
|
+
#
|
|
10
|
+
# The builder is the only place a reply is constructed, because the rules
|
|
11
|
+
# below are the ones that keep a half-finished Rails request from widening
|
|
12
|
+
# access:
|
|
13
|
+
#
|
|
14
|
+
# * `denied` and `fault` carry no effects, no state replacement, and no
|
|
15
|
+
# identity. They are a decision (or a failure), never a state change.
|
|
16
|
+
# Anything the callbacks collected before the refusal is dropped here,
|
|
17
|
+
# not filtered by the caller.
|
|
18
|
+
# * An unchanged state is *omitted*. Absent and `null` both mean
|
|
19
|
+
# unchanged, `{}` means clear — so a reply that emitted `{}` for "I
|
|
20
|
+
# did not touch it" would silently wipe the session's declared state.
|
|
21
|
+
# * `identity` belongs to `identify` only, and an empty map there is a
|
|
22
|
+
# valid accepted anonymous connection.
|
|
23
|
+
# * `new_revision` never moves backwards: accepted stateful operations
|
|
24
|
+
# advance it by one, everything else echoes `expected_revision`.
|
|
25
|
+
#
|
|
26
|
+
# `validate!` runs the same limit checks Rust runs on receipt, so an
|
|
27
|
+
# envelope the relay would reject never leaves this process.
|
|
28
|
+
class Reply
|
|
29
|
+
UNCHANGED = :unchanged
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def accepted(request, effects: [], state: UNCHANGED, subscription_state: UNCHANGED,
|
|
33
|
+
identity: nil, describe: nil)
|
|
34
|
+
new(
|
|
35
|
+
request: request,
|
|
36
|
+
outcome: OUTCOME_ACCEPTED,
|
|
37
|
+
effects: effects,
|
|
38
|
+
state: state,
|
|
39
|
+
subscription_state: subscription_state,
|
|
40
|
+
identity: identity,
|
|
41
|
+
describe: describe
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def denied(request, reason)
|
|
46
|
+
new(request: request, outcome: OUTCOME_DENIED, reason: reason)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fault(request, reason)
|
|
50
|
+
new(request: request, outcome: OUTCOME_FAULT, reason: reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The capability handshake. Not stateful: the revision is echoed.
|
|
54
|
+
def describe(request, supported_versions: [VERSION], software_version: nil,
|
|
55
|
+
capabilities: [], ready: true)
|
|
56
|
+
accepted(
|
|
57
|
+
request,
|
|
58
|
+
describe: {
|
|
59
|
+
"supported_versions" => supported_versions,
|
|
60
|
+
"software_version" => software_version.to_s,
|
|
61
|
+
"capabilities" => capabilities,
|
|
62
|
+
"ready" => ready
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(request:, outcome:, effects: [], state: UNCHANGED,
|
|
69
|
+
subscription_state: UNCHANGED, identity: nil, reason: nil, describe: nil)
|
|
70
|
+
unless OUTCOMES.include?(outcome)
|
|
71
|
+
raise ProtocolError.new("unknown outcome #{outcome.inspect}", field: "outcome")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@operation = request.operation
|
|
75
|
+
@request_id = request.request_id
|
|
76
|
+
@sequence = request.sequence
|
|
77
|
+
@expected_revision = request.expected_revision
|
|
78
|
+
@outcome = outcome
|
|
79
|
+
@reason = reason.nil? ? nil : truncate_reason(reason.to_s)
|
|
80
|
+
@describe = describe
|
|
81
|
+
|
|
82
|
+
if accepted?
|
|
83
|
+
@effects = effects.respond_to?(:to_a) ? effects.to_a : Array(effects)
|
|
84
|
+
@state = state
|
|
85
|
+
@subscription_state = subscription_state
|
|
86
|
+
@identity = identity
|
|
87
|
+
else
|
|
88
|
+
# Fail closed: a refusal or a failure changes nothing, whatever the
|
|
89
|
+
# callbacks managed to collect before it.
|
|
90
|
+
@effects = [].freeze
|
|
91
|
+
@state = UNCHANGED
|
|
92
|
+
@subscription_state = UNCHANGED
|
|
93
|
+
@identity = nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attr_reader :operation, :outcome, :request_id, :sequence, :effects, :reason, :describe
|
|
98
|
+
|
|
99
|
+
def accepted?
|
|
100
|
+
@outcome == OUTCOME_ACCEPTED
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def denied?
|
|
104
|
+
@outcome == OUTCOME_DENIED
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fault?
|
|
108
|
+
@outcome == OUTCOME_FAULT
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Accepted stateful operations advance the revision; `describe` and any
|
|
112
|
+
# non-acceptance echo what the relay believed.
|
|
113
|
+
def new_revision
|
|
114
|
+
return @expected_revision unless accepted? && V1.stateful?(operation)
|
|
115
|
+
|
|
116
|
+
@expected_revision + 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def state
|
|
120
|
+
@state == UNCHANGED ? nil : @state
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def subscription_state
|
|
124
|
+
@subscription_state == UNCHANGED ? nil : @subscription_state
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def identity
|
|
128
|
+
@identity
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_h
|
|
132
|
+
validate!
|
|
133
|
+
|
|
134
|
+
hash = {
|
|
135
|
+
"version" => VERSION,
|
|
136
|
+
"operation" => operation,
|
|
137
|
+
"outcome" => outcome,
|
|
138
|
+
"request_id" => request_id,
|
|
139
|
+
"sequence" => sequence,
|
|
140
|
+
"new_revision" => new_revision
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Absent means unchanged. Never `null`, never `{}` for "untouched".
|
|
144
|
+
hash["state"] = @state unless @state == UNCHANGED
|
|
145
|
+
hash["subscription_state"] = @subscription_state unless @subscription_state == UNCHANGED
|
|
146
|
+
hash["identity"] = @identity || {} if emits_identity?
|
|
147
|
+
hash["effects"] = @effects
|
|
148
|
+
hash["reason"] = @reason unless @reason.nil?
|
|
149
|
+
hash["describe"] = @describe unless @describe.nil?
|
|
150
|
+
hash
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def to_json(*args)
|
|
154
|
+
JSON.generate(to_h, *args)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The same checks Rust runs on receipt.
|
|
158
|
+
def validate!
|
|
159
|
+
V1.validate_uuid!("request_id", request_id)
|
|
160
|
+
|
|
161
|
+
unless @reason.nil?
|
|
162
|
+
V1.validate_bounded!("reason", @reason, MAX_REASON_LEN)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
unless accepted?
|
|
166
|
+
raise_scope("effects") unless @effects.empty?
|
|
167
|
+
raise_scope("state") unless @state == UNCHANGED
|
|
168
|
+
raise_scope("subscription_state") unless @subscription_state == UNCHANGED
|
|
169
|
+
raise_scope("identity") unless @identity.nil? || @identity.empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if @effects.size > MAX_EFFECTS
|
|
173
|
+
raise ProtocolError.new(
|
|
174
|
+
"one reply may carry at most #{MAX_EFFECTS} effects", field: "effects"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
@effects.each { |effect| validate_effect!(effect) }
|
|
178
|
+
|
|
179
|
+
V1.validate_state_map!("identity", @identity) unless @identity.nil?
|
|
180
|
+
V1.validate_state_map!("state", @state) unless @state == UNCHANGED
|
|
181
|
+
unless @subscription_state == UNCHANGED
|
|
182
|
+
V1.validate_state_map!("subscription_state", @subscription_state)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
validate_describe! if operation == OPERATION_DESCRIBE && accepted?
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def emits_identity?
|
|
192
|
+
accepted? && operation == OPERATION_IDENTIFY
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def raise_scope(field)
|
|
196
|
+
raise ProtocolError.new("#{field} is not allowed on a #{outcome} reply", field: field)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_effect!(effect)
|
|
200
|
+
kind = effect["kind"]
|
|
201
|
+
unless EFFECT_KINDS.include?(kind)
|
|
202
|
+
raise ProtocolError.new("unknown effect kind #{kind.inspect}", field: "kind")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
case kind
|
|
206
|
+
when EFFECT_ADD_STREAM, EFFECT_REMOVE_STREAM
|
|
207
|
+
V1.validate_identifier!(effect["identifier"])
|
|
208
|
+
V1.validate_stream!(effect["stream"])
|
|
209
|
+
when EFFECT_REMOVE_ALL_STREAMS
|
|
210
|
+
V1.validate_identifier!(effect["identifier"])
|
|
211
|
+
when EFFECT_TRANSMIT
|
|
212
|
+
V1.validate_identifier!(effect["identifier"])
|
|
213
|
+
V1.validate_json_text!("json_payload", effect["json_payload"])
|
|
214
|
+
when EFFECT_CLOSE
|
|
215
|
+
V1.validate_close_reason!(effect["reason"])
|
|
216
|
+
when EFFECT_PRESENCE_GRANT
|
|
217
|
+
V1.validate_identifier!(effect["identifier"])
|
|
218
|
+
V1.validate_stream!(effect["stream"])
|
|
219
|
+
V1.validate_bounded!("subject_id", effect["subject_id"], MAX_SUBJECT_ID_LEN)
|
|
220
|
+
unless effect["ttl_ms"].is_a?(Integer) && effect["ttl_ms"].positive?
|
|
221
|
+
raise ProtocolError.new("ttl_ms must be greater than zero", field: "ttl_ms")
|
|
222
|
+
end
|
|
223
|
+
when EFFECT_PRESENCE_REVOKE
|
|
224
|
+
V1.validate_identifier!(effect["identifier"])
|
|
225
|
+
V1.validate_stream!(effect["stream"])
|
|
226
|
+
V1.validate_bounded!("subject_id", effect["subject_id"], MAX_SUBJECT_ID_LEN)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def validate_describe!
|
|
231
|
+
unless @describe.is_a?(Hash)
|
|
232
|
+
raise ProtocolError.new("an accepted describe requires a describe payload",
|
|
233
|
+
field: "describe")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
versions = @describe["supported_versions"]
|
|
237
|
+
unless versions.is_a?(Array) && versions.include?(VERSION)
|
|
238
|
+
raise ProtocolError.new("describe must advertise version #{VERSION}",
|
|
239
|
+
field: "supported_versions")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
capabilities = @describe["capabilities"] || []
|
|
243
|
+
if capabilities.size > MAX_CAPABILITIES
|
|
244
|
+
raise ProtocolError.new(
|
|
245
|
+
"describe may advertise at most #{MAX_CAPABILITIES} capabilities",
|
|
246
|
+
field: "capabilities"
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
capabilities.each { |capability| V1.validate_bounded!("capability", capability, MAX_LABEL_LEN) }
|
|
250
|
+
|
|
251
|
+
software_version = @describe["software_version"].to_s
|
|
252
|
+
unless software_version.empty?
|
|
253
|
+
V1.validate_bounded!("software_version", software_version, MAX_LABEL_LEN)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The reason is a diagnostic for relay logs; it is never shown to a
|
|
258
|
+
# browser, and it is bounded so a stack-trace-sized string cannot make
|
|
259
|
+
# the reply unparseable.
|
|
260
|
+
def truncate_reason(reason)
|
|
261
|
+
return reason if reason.bytesize <= MAX_REASON_LEN
|
|
262
|
+
|
|
263
|
+
reason.byteslice(0, MAX_REASON_LEN).scrub("")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../v1"
|
|
4
|
+
|
|
5
|
+
module Orbitcast
|
|
6
|
+
module Protocol
|
|
7
|
+
module V1
|
|
8
|
+
# One request from a relay node to this Rails endpoint.
|
|
9
|
+
#
|
|
10
|
+
# `POST {mount}/{operation}`; the envelope's `operation` must match the
|
|
11
|
+
# route, which is what stops a body being replayed against a different
|
|
12
|
+
# operation.
|
|
13
|
+
#
|
|
14
|
+
# Parsing is tolerant in exactly the documented ways (unknown fields
|
|
15
|
+
# ignored, absent fields defaulted); `validate!` is where a defaulted
|
|
16
|
+
# envelope fails closed. The browser cannot submit any of this: the URL,
|
|
17
|
+
# origin, client address, and allowlisted headers are reconstructed by the
|
|
18
|
+
# relay from validated metadata.
|
|
19
|
+
class Request
|
|
20
|
+
# Everything Rails is allowed to know about the original WebSocket
|
|
21
|
+
# request.
|
|
22
|
+
class ConnectionSnapshot
|
|
23
|
+
def self.from(hash)
|
|
24
|
+
hash = {} unless hash.is_a?(Hash)
|
|
25
|
+
new(
|
|
26
|
+
url: hash["url"].is_a?(String) ? hash["url"] : "",
|
|
27
|
+
origin: hash["origin"].is_a?(String) ? hash["origin"] : nil,
|
|
28
|
+
client_address: hash["client_address"].is_a?(String) ? hash["client_address"] : "",
|
|
29
|
+
headers: hash["headers"].is_a?(Hash) ? hash["headers"] : {},
|
|
30
|
+
identity: hash["identity"].is_a?(Hash) ? hash["identity"] : {},
|
|
31
|
+
state: hash["state"].is_a?(Hash) ? hash["state"] : {}
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(url:, origin:, client_address:, headers:, identity:, state:)
|
|
36
|
+
@url = url
|
|
37
|
+
@origin = origin
|
|
38
|
+
@client_address = client_address
|
|
39
|
+
@headers = headers.freeze
|
|
40
|
+
@identity = identity.freeze
|
|
41
|
+
@state = state.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :url, :origin, :client_address, :headers, :identity, :state
|
|
45
|
+
|
|
46
|
+
def validate!
|
|
47
|
+
V1.validate_bounded!("url", url, MAX_URL_LEN)
|
|
48
|
+
V1.validate_bounded!("client_address", client_address, MAX_ADDRESS_LEN)
|
|
49
|
+
V1.validate_bounded!("origin", origin, MAX_ADDRESS_LEN) unless origin.nil?
|
|
50
|
+
|
|
51
|
+
if headers.size > MAX_HEADERS
|
|
52
|
+
raise ProtocolError.new(
|
|
53
|
+
"headers has #{headers.size} entries; the maximum is #{MAX_HEADERS}",
|
|
54
|
+
field: "headers"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
headers.each do |name, value|
|
|
59
|
+
V1.validate_bounded!("header_name", name, MAX_HEADER_NAME_LEN)
|
|
60
|
+
if name.match?(/[[:upper:]\s]/)
|
|
61
|
+
raise ProtocolError.new(
|
|
62
|
+
"header name #{name.inspect} must be lowercase and unpadded",
|
|
63
|
+
field: "header_name"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
if HOP_BY_HOP_HEADERS.include?(name)
|
|
67
|
+
raise ProtocolError.new(
|
|
68
|
+
"header #{name.inspect} is hop-by-hop and is never forwarded",
|
|
69
|
+
field: "header_name"
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
unless value.is_a?(String) && value.bytesize <= MAX_HEADER_VALUE_LEN
|
|
73
|
+
raise ProtocolError.new("header #{name.inspect} has an unusable value",
|
|
74
|
+
field: "header_value")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
V1.validate_state_map!("identity", identity)
|
|
79
|
+
V1.validate_state_map!("state", state)
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_h
|
|
84
|
+
{
|
|
85
|
+
"url" => url,
|
|
86
|
+
"origin" => origin,
|
|
87
|
+
"client_address" => client_address,
|
|
88
|
+
"headers" => headers,
|
|
89
|
+
"identity" => identity,
|
|
90
|
+
"state" => state
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One subscription as the relay currently holds it. `identifier` is the
|
|
96
|
+
# client's original string, byte for byte; `params` is a convenience
|
|
97
|
+
# decode of it and is never authoritative.
|
|
98
|
+
class SubscriptionSnapshot
|
|
99
|
+
def self.from(hash)
|
|
100
|
+
hash = {} unless hash.is_a?(Hash)
|
|
101
|
+
new(
|
|
102
|
+
identifier: hash["identifier"].is_a?(String) ? hash["identifier"] : "",
|
|
103
|
+
params: hash["params"].is_a?(Hash) ? hash["params"] : {},
|
|
104
|
+
streams: hash["streams"].is_a?(Array) ? hash["streams"] : [],
|
|
105
|
+
state: hash["state"].is_a?(Hash) ? hash["state"] : {}
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def initialize(identifier:, params:, streams:, state:)
|
|
110
|
+
@identifier = identifier
|
|
111
|
+
@params = params.freeze
|
|
112
|
+
@streams = streams.freeze
|
|
113
|
+
@state = state.freeze
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
attr_reader :identifier, :params, :streams, :state
|
|
117
|
+
|
|
118
|
+
def validate!
|
|
119
|
+
V1.validate_identifier!(identifier)
|
|
120
|
+
|
|
121
|
+
if streams.size > MAX_SUBSCRIPTION_STREAMS
|
|
122
|
+
raise ProtocolError.new(
|
|
123
|
+
"streams has #{streams.size} entries; the maximum is " \
|
|
124
|
+
"#{MAX_SUBSCRIPTION_STREAMS}", field: "streams"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
streams.each { |stream| V1.validate_stream!(stream) }
|
|
128
|
+
|
|
129
|
+
V1.validate_state_map!("params", params)
|
|
130
|
+
V1.validate_state_map!("state", state)
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_h
|
|
135
|
+
{
|
|
136
|
+
"identifier" => identifier,
|
|
137
|
+
"params" => params,
|
|
138
|
+
"streams" => streams,
|
|
139
|
+
"state" => state
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The `message`/`perform` payload of a `run_action` request.
|
|
145
|
+
class ActionPayload
|
|
146
|
+
def self.from(hash)
|
|
147
|
+
hash = {} unless hash.is_a?(Hash)
|
|
148
|
+
new(
|
|
149
|
+
identifier: hash["identifier"].is_a?(String) ? hash["identifier"] : "",
|
|
150
|
+
json_data: hash["json_data"].is_a?(String) ? hash["json_data"] : ""
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def initialize(identifier:, json_data:)
|
|
155
|
+
@identifier = identifier
|
|
156
|
+
@json_data = json_data
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
attr_reader :identifier, :json_data
|
|
160
|
+
|
|
161
|
+
def validate!
|
|
162
|
+
V1.validate_identifier!(identifier)
|
|
163
|
+
V1.validate_json_text!("json_data", json_data)
|
|
164
|
+
self
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The decoded command data. Rails reads `data["action"]` out of the
|
|
168
|
+
# bytes the client actually sent.
|
|
169
|
+
def data
|
|
170
|
+
JSON.parse(json_data)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def to_h
|
|
174
|
+
{ "identifier" => identifier, "json_data" => json_data }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The final snapshot handed to `release_connection`.
|
|
179
|
+
class ReleasePayload
|
|
180
|
+
def self.from(hash)
|
|
181
|
+
hash = {} unless hash.is_a?(Hash)
|
|
182
|
+
subscriptions = hash["subscriptions"].is_a?(Array) ? hash["subscriptions"] : []
|
|
183
|
+
new(
|
|
184
|
+
reason: hash["reason"].is_a?(String) ? hash["reason"] : "",
|
|
185
|
+
reconnect: hash["reconnect"] == true,
|
|
186
|
+
subscriptions: subscriptions.map { |entry| SubscriptionSnapshot.from(entry) }
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def initialize(reason:, reconnect:, subscriptions:)
|
|
191
|
+
@reason = reason
|
|
192
|
+
@reconnect = reconnect
|
|
193
|
+
@subscriptions = subscriptions.freeze
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
attr_reader :reason, :reconnect, :subscriptions
|
|
197
|
+
|
|
198
|
+
def reconnect?
|
|
199
|
+
@reconnect
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate!
|
|
203
|
+
V1.validate_close_reason!(reason)
|
|
204
|
+
|
|
205
|
+
if subscriptions.size > MAX_RELEASE_SUBSCRIPTIONS
|
|
206
|
+
raise ProtocolError.new(
|
|
207
|
+
"subscriptions has #{subscriptions.size} entries; the maximum is " \
|
|
208
|
+
"#{MAX_RELEASE_SUBSCRIPTIONS}", field: "subscriptions"
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
subscriptions.each(&:validate!)
|
|
212
|
+
self
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def to_h
|
|
216
|
+
{
|
|
217
|
+
"reason" => reason,
|
|
218
|
+
"reconnect" => reconnect,
|
|
219
|
+
"subscriptions" => subscriptions.map(&:to_h)
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Decode one request body. Raises ProtocolError when the body is not an
|
|
225
|
+
# object or names an operation this release does not implement: an
|
|
226
|
+
# unknown tagged value is a hard error, never a tolerated unknown.
|
|
227
|
+
def self.from(hash)
|
|
228
|
+
unless hash.is_a?(Hash)
|
|
229
|
+
raise ProtocolError.new("request body must be a JSON object", field: "body")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
operation = hash["operation"]
|
|
233
|
+
unless OPERATIONS.include?(operation)
|
|
234
|
+
raise ProtocolError.new("unknown operation #{operation.inspect}", field: "operation")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
new(
|
|
238
|
+
version: V1.version_of(hash),
|
|
239
|
+
operation: operation,
|
|
240
|
+
namespace: string_or_empty(hash["namespace"]),
|
|
241
|
+
node_id: string_or_empty(hash["node_id"]),
|
|
242
|
+
session_id: string_or_empty(hash["session_id"]),
|
|
243
|
+
request_id: string_or_empty(hash["request_id"]),
|
|
244
|
+
sequence: integer_or_zero(hash["sequence"]),
|
|
245
|
+
expected_revision: integer_or_zero(hash["expected_revision"]),
|
|
246
|
+
deadline_ms: integer_or_zero(hash["deadline_ms"]),
|
|
247
|
+
connection: hash.key?("connection") ? ConnectionSnapshot.from(hash["connection"]) : nil,
|
|
248
|
+
subscription: hash.key?("subscription") ? SubscriptionSnapshot.from(hash["subscription"]) : nil,
|
|
249
|
+
action: hash.key?("action") ? ActionPayload.from(hash["action"]) : nil,
|
|
250
|
+
release: hash.key?("release") ? ReleasePayload.from(hash["release"]) : nil
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def self.parse(text)
|
|
255
|
+
from(JSON.parse(text))
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.string_or_empty(value)
|
|
259
|
+
value.is_a?(String) ? value : ""
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def self.integer_or_zero(value)
|
|
263
|
+
value.is_a?(Integer) ? value : 0
|
|
264
|
+
end
|
|
265
|
+
private_class_method :string_or_empty, :integer_or_zero
|
|
266
|
+
|
|
267
|
+
def initialize(version:, operation:, namespace:, node_id:, session_id:, request_id:,
|
|
268
|
+
sequence:, expected_revision:, deadline_ms:,
|
|
269
|
+
connection: nil, subscription: nil, action: nil, release: nil)
|
|
270
|
+
@version = version
|
|
271
|
+
@operation = operation
|
|
272
|
+
@namespace = namespace
|
|
273
|
+
@node_id = node_id
|
|
274
|
+
@session_id = session_id
|
|
275
|
+
@request_id = request_id
|
|
276
|
+
@sequence = sequence
|
|
277
|
+
@expected_revision = expected_revision
|
|
278
|
+
@deadline_ms = deadline_ms
|
|
279
|
+
@connection = connection
|
|
280
|
+
@subscription = subscription
|
|
281
|
+
@action = action
|
|
282
|
+
@release = release
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
attr_reader :version, :operation, :namespace, :node_id, :session_id, :request_id,
|
|
286
|
+
:sequence, :expected_revision, :deadline_ms,
|
|
287
|
+
:connection, :subscription, :action, :release
|
|
288
|
+
|
|
289
|
+
def stateful?
|
|
290
|
+
V1.stateful?(operation)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def teardown?
|
|
294
|
+
V1.teardown?(operation)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Semantic validation. Parsing defaults never bypass this.
|
|
298
|
+
def validate!
|
|
299
|
+
V1.validate_version!(version)
|
|
300
|
+
V1.validate_namespace!(namespace)
|
|
301
|
+
V1.validate_bounded!("node_id", node_id, MAX_NODE_ID_LEN)
|
|
302
|
+
V1.validate_uuid!("session_id", session_id)
|
|
303
|
+
V1.validate_uuid!("request_id", request_id)
|
|
304
|
+
|
|
305
|
+
if deadline_ms <= 0 || deadline_ms > MAX_DEADLINE_MS
|
|
306
|
+
raise ProtocolError.new(
|
|
307
|
+
"deadline_ms must be 1..#{MAX_DEADLINE_MS}, got #{deadline_ms}", field: "deadline_ms"
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
require_payload!(connection, "connection") unless operation == OPERATION_DESCRIBE
|
|
312
|
+
case operation
|
|
313
|
+
when OPERATION_OPEN_CHANNEL, OPERATION_CLOSE_CHANNEL
|
|
314
|
+
require_payload!(subscription, "subscription")
|
|
315
|
+
when OPERATION_RUN_ACTION
|
|
316
|
+
require_payload!(subscription, "subscription")
|
|
317
|
+
require_payload!(action, "action")
|
|
318
|
+
when OPERATION_RELEASE_CONNECTION
|
|
319
|
+
require_payload!(release, "release")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# A payload that is present but not required is still validated: a
|
|
323
|
+
# tolerated field is never an unvalidated field.
|
|
324
|
+
connection&.validate!
|
|
325
|
+
subscription&.validate!
|
|
326
|
+
action&.validate!
|
|
327
|
+
release&.validate!
|
|
328
|
+
self
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def to_h
|
|
332
|
+
hash = {
|
|
333
|
+
"version" => version,
|
|
334
|
+
"operation" => operation,
|
|
335
|
+
"namespace" => namespace,
|
|
336
|
+
"node_id" => node_id,
|
|
337
|
+
"session_id" => session_id,
|
|
338
|
+
"request_id" => request_id,
|
|
339
|
+
"sequence" => sequence,
|
|
340
|
+
"expected_revision" => expected_revision,
|
|
341
|
+
"deadline_ms" => deadline_ms
|
|
342
|
+
}
|
|
343
|
+
hash["connection"] = connection.to_h if connection
|
|
344
|
+
hash["subscription"] = subscription.to_h if subscription
|
|
345
|
+
hash["action"] = action.to_h if action
|
|
346
|
+
hash["release"] = release.to_h if release
|
|
347
|
+
hash
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def to_json(*args)
|
|
351
|
+
JSON.generate(to_h, *args)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
def require_payload!(payload, field)
|
|
357
|
+
return if payload
|
|
358
|
+
|
|
359
|
+
raise ProtocolError.new("#{operation} requires a #{field} payload", field: field)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|