volcano-sdk 0.30.0 → 0.32.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/volcano/auth.rb +4 -48
  3. data/lib/volcano/auth_session_construction.rb +67 -0
  4. data/lib/volcano/auth_sign_up.rb +2 -0
  5. data/lib/volcano/realtime/broadcast_delivery.rb +26 -2
  6. data/lib/volcano/realtime/channel_callbacks.rb +19 -14
  7. data/lib/volcano/realtime/channel_lifecycle.rb +9 -9
  8. data/lib/volcano/realtime/connection.rb +9 -5
  9. data/lib/volcano/realtime/connection_callbacks.rb +5 -1
  10. data/lib/volcano/realtime/lifecycle.rb +2 -1
  11. data/lib/volcano/realtime/postgres_batch.rb +11 -3
  12. data/lib/volcano/realtime/postgres_changes.rb +31 -16
  13. data/lib/volcano/realtime/postgres_database.rb +14 -1
  14. data/lib/volcano/realtime/postgres_delivery.rb +6 -1
  15. data/lib/volcano/realtime/postgres_expansion.rb +10 -4
  16. data/lib/volcano/realtime/presence.rb +18 -4
  17. data/lib/volcano/realtime/presence_state.rb +23 -6
  18. data/lib/volcano/realtime/protocol.rb +14 -9
  19. data/lib/volcano/realtime/protocol_dispatch.rb +25 -7
  20. data/lib/volcano/realtime/protocol_io.rb +8 -4
  21. data/lib/volcano/realtime/protocol_lifecycle.rb +2 -0
  22. data/lib/volcano/realtime/protocol_publication_dispatch.rb +12 -10
  23. data/lib/volcano/realtime/protocol_recovery.rb +7 -3
  24. data/lib/volcano/realtime/protocol_recovery_dispatch.rb +2 -0
  25. data/lib/volcano/realtime/reconnect.rb +4 -1
  26. data/lib/volcano/realtime.rb +122 -99
  27. data/lib/volcano/storage.rb +18 -34
  28. data/lib/volcano/storage_models.rb +18 -5
  29. data/lib/volcano/storage_mutations.rb +8 -3
  30. data/lib/volcano/storage_object_response.rb +88 -0
  31. data/lib/volcano/storage_response.rb +92 -0
  32. data/lib/volcano/storage_resumable_upload.rb +13 -2
  33. data/lib/volcano/storage_session_adapter.rb +61 -0
  34. data/lib/volcano/storage_transport_adapter.rb +73 -0
  35. data/lib/volcano/storage_upload_models.rb +51 -27
  36. data/lib/volcano/storage_upload_sessions.rb +1 -35
  37. data/lib/volcano/version.rb +1 -1
  38. data/lib/volcano.rb +6 -1
  39. data/sig/auth_credentials.rbs +15 -0
  40. data/sig/realtime.rbs +46 -0
  41. data/sig/realtime_postgres_change.rbs +1 -2
  42. data/sig/storage.rbs +29 -0
  43. data/sig/storage_records.rbs +3 -1
  44. data/sig/upload_session_status.rbs +3 -1
  45. metadata +9 -1
@@ -32,134 +32,148 @@ module Volcano
32
32
 
33
33
  # Recursively snapshots JSON-compatible values for callback and state safety.
34
34
  module Immutable
35
- module_function
36
-
37
- def optional(value)
38
- value && call(value)
35
+ def self.optional(value)
36
+ value && snapshot(value)
39
37
  end
40
38
 
41
- def call(value)
39
+ def self.call(value) = snapshot(value)
40
+
41
+ def self.snapshot(value)
42
42
  case value
43
43
  when Hash then call_hash(value)
44
- when Array then value.map { |child| call(child) }.freeze
44
+ when Array then value.map { |child| snapshot(child) }.freeze
45
45
  when String then value.dup.freeze
46
46
  else value.freeze
47
47
  end
48
48
  end
49
49
 
50
- def call_hash(value)
51
- value.to_h { |key, item| [call(key), call(item)] }.freeze
50
+ def self.call_hash(value)
51
+ value.to_h { |key, item| [snapshot(key), snapshot(item)] }.freeze
52
52
  end
53
53
  end
54
54
  private_constant :Immutable
55
55
 
56
- PresenceInfo = Data.define(:client, :user, :data) do
56
+ PresenceInfo = Data.define(:client, :user, :data)
57
+
58
+ # An immutable snapshot of one presence client.
59
+ class PresenceInfo
60
+ # @dynamic client, user, data, members, with, to_h, deconstruct, deconstruct_keys
61
+ # @dynamic self.[], self.members
57
62
  def initialize(client:, user: nil, data: {})
58
- super(
59
- client: Immutable.call(client.to_s),
60
- user: user.nil? ? nil : Immutable.call(user.to_s),
61
- data: Immutable.call(data)
62
- )
63
+ client = Immutable.call(client.to_s)
64
+ user = user.nil? ? nil : Immutable.call(user.to_s)
65
+ data = Immutable.call(data)
66
+ super
63
67
  end
64
68
  end
65
69
 
66
70
  CHANNEL_TYPES = %i[broadcast presence postgres].freeze
67
71
 
68
- def initialize(client, api_url:, socket_factory: nil, reconnect_delay: nil)
69
- @client = client
70
- @api_url = api_url
71
- @socket_factory = socket_factory || method(:open_socket)
72
- @reconnect_delay = reconnect_delay || method(:default_reconnect_delay)
73
- initialize_realtime_state
74
- initialize_postgres_database
75
- end
72
+ # Owns the public realtime facade and its channel collection.
73
+ module Core
74
+ # @dynamic initialize_postgres_database, connect_protocol, close_protocol, public_error
75
+ # @dynamic protocol_error, open_socket, default_reconnect_delay, __send__
76
+ def initialize(client, api_url:, socket_factory: nil, reconnect_delay: nil)
77
+ @client = client
78
+ @api_url = api_url
79
+ @socket_factory = socket_factory || ->(address) { open_socket(address) }
80
+ @reconnect_delay = reconnect_delay || ->(attempt) { default_reconnect_delay(attempt) }
81
+ initialize_realtime_state
82
+ initialize_postgres_database
83
+ end
76
84
 
77
- def initialize_realtime_state
78
- @protocol = nil
79
- @protocol_lock = nil
80
- @channel_lock = nil
81
- @channels = {}
82
- @connection_callbacks = { connect: {}, disconnect: {}, error: {} }
83
- @next_connection_callback_id = 0
84
- @protocol_session_lineage = nil
85
- @reconnect_task = nil
86
- @closed = false
87
- end
88
- private :initialize_realtime_state
89
-
90
- def channel(
91
- name, type: :broadcast, auto_fetch: true,
92
- fetch_batch_window_ms: 20, fetch_max_batch_size: 50
93
- )
94
- type = normalize_channel_type(type)
95
- batch_config = PostgresBatchConfig.new(
96
- auto_fetch:, batch_window_ms: fetch_batch_window_ms,
97
- max_batch_size: fetch_max_batch_size
85
+ def initialize_realtime_state
86
+ @protocol = nil
87
+ @protocol_lock = nil
88
+ @channel_lock = nil
89
+ @channels = {}
90
+ @connection_callbacks = { connect: {}, disconnect: {}, error: {} }
91
+ @next_connection_callback_id = 0
92
+ @protocol_session_lineage = nil
93
+ @reconnect_task = nil
94
+ @closed = false
95
+ end
96
+ private :initialize_realtime_state
97
+
98
+ def channel(
99
+ name, type: :broadcast, auto_fetch: true,
100
+ fetch_batch_window_ms: 20, fetch_max_batch_size: 50
98
101
  )
99
- channel_lock.acquire do
100
- ensure_open!
101
- channel_name = "#{type}:#{name}"
102
- channel = @channels[channel_name]
103
- next cached_channel(channel, batch_config) if channel
102
+ type = normalize_channel_type(type)
103
+ batch_config = PostgresBatchConfig.new(
104
+ auto_fetch:, batch_window_ms: fetch_batch_window_ms,
105
+ max_batch_size: fetch_max_batch_size
106
+ )
107
+ channel_lock.acquire do
108
+ ensure_open!
109
+ channel_name = "#{type}:#{name}"
110
+ channel = @channels[channel_name]
111
+ next cached_channel(channel, batch_config) if channel
104
112
 
105
- @channels[channel_name] = build_channel(channel_name, type, batch_config)
113
+ @channels[channel_name] = build_channel(channel_name, type, batch_config)
114
+ end
106
115
  end
107
- end
108
-
109
- def protocol
110
- ensure_open!
111
- return @protocol if @protocol
112
116
 
113
- protocol_lock.acquire do
117
+ def protocol
114
118
  ensure_open!
115
- @protocol ||= connect_protocol
119
+ current_protocol = @protocol
120
+ return current_protocol if current_protocol
121
+
122
+ protocol_lock.acquire do
123
+ ensure_open!
124
+ @protocol ||= connect_protocol
125
+ end
126
+ rescue StandardError => e
127
+ raise public_error(e), cause: nil
116
128
  end
117
- rescue StandardError => e
118
- raise public_error(e), cause: nil
119
- end
120
- private :protocol
129
+ private :protocol
121
130
 
122
- def disconnect
123
- @protocol_lock ? @protocol_lock.acquire { close_protocol } : close_protocol
124
- rescue StandardError => e
125
- raise public_error(e), cause: nil
126
- end
131
+ def disconnect
132
+ lock = @protocol_lock
133
+ lock ? lock.acquire { close_protocol } : close_protocol
134
+ rescue StandardError => e
135
+ raise public_error(e), cause: nil
136
+ end
127
137
 
128
- def ensure_open!
129
- raise ClosedError, 'realtime connection closed' if @closed
138
+ def ensure_open!
139
+ raise ClosedError, 'realtime connection closed' if @closed
130
140
 
131
- self
132
- end
141
+ self
142
+ end
133
143
 
134
- private
144
+ private
135
145
 
136
- def normalize_channel_type(type)
137
- normalized = type.to_s.to_sym
138
- return normalized if CHANNEL_TYPES.include?(normalized)
146
+ def normalize_channel_type(type)
147
+ normalized = type.to_s.to_sym
148
+ return normalized if CHANNEL_TYPES.include?(normalized)
139
149
 
140
- raise ArgumentError, "unsupported realtime channel type: #{type}"
141
- end
150
+ raise ArgumentError, "unsupported realtime channel type: #{type}"
151
+ end
142
152
 
143
- def report_channel_error(error) = protocol_error(public_error(error))
153
+ def report_channel_error(error) = protocol_error(public_error(error))
144
154
 
145
- def cached_channel(channel, batch_config)
146
- channel.__send__(:ensure_fetch_config!, batch_config)
147
- channel
148
- end
155
+ def cached_channel(channel, batch_config)
156
+ channel.__send__(:ensure_fetch_config!, batch_config)
157
+ channel
158
+ end
149
159
 
150
- def build_channel(name, type, batch_config)
151
- Channel.new(self, method(:protocol), name, type, batch_config:)
152
- end
160
+ def build_channel(name, type, batch_config)
161
+ Channel.new(self, -> { protocol }, name, type, batch_config:)
162
+ end
153
163
 
154
- def protocol_lock
155
- require 'async/semaphore'
156
- @protocol_lock ||= Async::Semaphore.new(1)
157
- end
164
+ def protocol_lock
165
+ require 'async/semaphore'
166
+ @protocol_lock ||= Async::Semaphore.new(1)
167
+ end
158
168
 
159
- def channel_lock
160
- require 'async/semaphore'
161
- @channel_lock ||= Async::Semaphore.new(1)
169
+ def channel_lock
170
+ require 'async/semaphore'
171
+ @channel_lock ||= Async::Semaphore.new(1)
172
+ end
162
173
  end
174
+ include Core
175
+
176
+ private_constant :Core
163
177
 
164
178
  # Represents one realtime broadcast channel.
165
179
  class Channel
@@ -175,14 +189,15 @@ module Volcano
175
189
 
176
190
  attr_reader :name
177
191
 
192
+ # @dynamic name
193
+
178
194
  def initialize(realtime, protocol_provider, name, type, batch_config:)
179
195
  @realtime = realtime
180
196
  @protocol_provider = protocol_provider
181
197
  @name = name.freeze
182
198
  @handler_registered = @subscribed = @subscription_desired = @closed = false
183
199
  @lifecycle_lock = nil
184
- @recovery_position = {}.freeze
185
- @recovery_lineage = nil
200
+ initialize_recovery_state
186
201
  initialize_callback_dispatch
187
202
  initialize_presence(type)
188
203
  initialize_postgres_delivery(batch_config)
@@ -208,7 +223,7 @@ module Volcano
208
223
 
209
224
  def unsubscribe
210
225
  state = with_lifecycle_lock { unsubscribe_protocol }
211
- emit_presence_sync(state)
226
+ emit_presence_sync(state) if state.is_a?(Hash)
212
227
  nil
213
228
  end
214
229
 
@@ -218,11 +233,12 @@ module Volcano
218
233
 
219
234
  def restore_subscription(protocol)
220
235
  state = with_lifecycle_lock do
221
- next unless restoration_needed?
222
-
223
- subscribe_protocol(protocol)
236
+ restoration_needed? ? subscribe_protocol(protocol) : nil
237
+ end
238
+ if state
239
+ subscribed_protocol, epoch = state
240
+ sync_presence(subscribed_protocol, epoch)
224
241
  end
225
- sync_presence(*state) if state
226
242
  nil
227
243
  end
228
244
 
@@ -240,6 +256,13 @@ module Volcano
240
256
 
241
257
  private
242
258
 
259
+ def initialize_recovery_state
260
+ # @type var recovery_position: Hash[Symbol, String | Integer]
261
+ recovery_position = {}
262
+ @recovery_position = recovery_position.freeze
263
+ @recovery_lineage = nil
264
+ end
265
+
243
266
  def subscribe_with_intent
244
267
  ensure_open!
245
268
  raise DuplicateSubscriptionError, "already subscribed to #{@name}" if @subscription_desired
@@ -254,8 +277,8 @@ module Volcano
254
277
 
255
278
  def with_lifecycle_lock(&)
256
279
  require 'async/semaphore'
257
- @lifecycle_lock ||= Async::Semaphore.new(1)
258
- @lifecycle_lock.acquire(&)
280
+ lock = @lifecycle_lock ||= Async::Semaphore.new(1)
281
+ lock.acquire(&)
259
282
  end
260
283
 
261
284
  def ensure_open!
@@ -6,8 +6,8 @@ module Volcano
6
6
  # Entry point for project object storage.
7
7
  class Storage
8
8
  def initialize(client, transport, api_url:, anon_key:)
9
- @client = client
10
- @transport = transport
9
+ @client = client.is_a?(StorageSessionAdapter) ? client : StorageSessionAdapter.new(client)
10
+ @transport = transport.is_a?(StorageTransportAdapter) ? transport : StorageTransportAdapter.new(transport)
11
11
  @api_url = api_url
12
12
  @anon_key = anon_key
13
13
  end
@@ -25,9 +25,11 @@ module Volcano
25
25
 
26
26
  # Operates on objects in one storage bucket.
27
27
  class StorageBucket
28
+ include StorageObjectResponse
29
+
28
30
  def initialize(client, transport, name, api_url:, anon_key:)
29
- @client = client
30
- @transport = transport
31
+ @client = client.is_a?(StorageSessionAdapter) ? client : StorageSessionAdapter.new(client)
32
+ @transport = transport.is_a?(StorageTransportAdapter) ? transport : StorageTransportAdapter.new(transport)
31
33
  @name = name.dup.freeze
32
34
  @api_url = api_url.dup.freeze
33
35
  @anon_key = anon_key.dup.freeze
@@ -36,7 +38,7 @@ module Volcano
36
38
 
37
39
  def upload(path, value, content_type: nil)
38
40
  mime_type = upload_content_type(content_type)
39
- path = storage_paths(path).first
41
+ path = storage_paths(path).fetch(0)
40
42
  binding = @client.capture_session_binding
41
43
  @client.session_token
42
44
  content = upload_bytes(value).freeze
@@ -49,11 +51,11 @@ module Volcano
49
51
  content_type: mime_type
50
52
  )
51
53
  end
52
- Transport.body(response, 201)
54
+ storage_payload(Transport.body(response, 201))
53
55
  end
54
56
 
55
57
  def download(path, range: nil)
56
- path = storage_paths(path).first
58
+ path = storage_paths(path).fetch(0)
57
59
  range = range&.dup&.freeze
58
60
  response = storage_request do |token|
59
61
  @transport.download_storage_object(
@@ -65,7 +67,7 @@ module Volcano
65
67
  end
66
68
  expected_status = range && response.status == 206 ? 206 : 200
67
69
  Transport.body(response, expected_status)
68
- response.data.b
70
+ storage_string(response.data).b
69
71
  end
70
72
 
71
73
  def list(prefix = '', limit: nil, cursor: nil)
@@ -80,11 +82,10 @@ module Volcano
80
82
  cursor: cursor
81
83
  )
82
84
  end
83
- payload = Transport.body(response, 200)
84
- next_cursor = payload['next_cursor']
85
+ payload = storage_payload(Transport.body(response, 200))
85
86
  StoragePage.new(
86
- objects: payload.fetch('objects', []).map { |object| storage_object(object) },
87
- next_cursor: next_cursor == '' ? nil : next_cursor
87
+ objects: storage_object_list(payload.fetch('objects', [])),
88
+ next_cursor: storage_cursor(payload['next_cursor'])
88
89
  )
89
90
  end
90
91
 
@@ -102,31 +103,14 @@ module Volcano
102
103
  end
103
104
 
104
105
  def upload_bytes(value)
105
- bytes = value.respond_to?(:read) ? value.read : value
106
+ bytes = if value.is_a?(String)
107
+ value
108
+ elsif value.respond_to?(:read)
109
+ value.read
110
+ end
106
111
  raise ArgumentError, 'upload data must be a String or IO' unless bytes.is_a?(String)
107
112
 
108
113
  bytes.b
109
114
  end
110
-
111
- def storage_object(payload)
112
- StorageObject.new(
113
- id: payload.fetch('id'),
114
- bucket_id: payload.fetch('bucket_id'),
115
- name: payload.fetch('name'),
116
- size: payload.fetch('size'),
117
- mime_type: payload.fetch('mime_type'),
118
- is_public: payload.fetch('is_public'),
119
- owner_id: payload['owner_id'],
120
- etag: payload['etag'],
121
- metadata: payload['metadata'],
122
- created_at: parse_time(payload['created_at']),
123
- updated_at: parse_time(payload['updated_at']),
124
- public_url: payload['public_url']
125
- )
126
- end
127
-
128
- def parse_time(value)
129
- value.is_a?(String) ? Time.iso8601(value) : value
130
- end
131
115
  end
132
116
  end
@@ -6,7 +6,13 @@ module Volcano
6
6
  ].freeze
7
7
  private_constant :STORAGE_OBJECT_ATTRIBUTES
8
8
 
9
- StorageObject = Data.define(*STORAGE_OBJECT_ATTRIBUTES) do
9
+ StorageObject = Data.define(*STORAGE_OBJECT_ATTRIBUTES)
10
+
11
+ # Validates and freezes object metadata returned by storage.
12
+ class StorageObject
13
+ # @dynamic id, bucket_id, name, size, mime_type, is_public, owner_id, etag
14
+ # @dynamic metadata, created_at, updated_at, public_url
15
+ # @dynamic members, with, to_h, deconstruct, deconstruct_keys, self.[], self.members
10
16
  def initialize(**attributes)
11
17
  unknown = attributes.keys - STORAGE_OBJECT_ATTRIBUTES
12
18
  raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
@@ -17,7 +23,8 @@ module Volcano
17
23
  values = STORAGE_OBJECT_ATTRIBUTES.to_h do |name|
18
24
  [name, immutable_value(attributes[name])]
19
25
  end
20
- super(**values)
26
+ attributes = values
27
+ super
21
28
  end
22
29
 
23
30
  private
@@ -40,10 +47,16 @@ module Volcano
40
47
  end
41
48
  end
42
49
 
43
- StoragePage = Data.define(:objects, :next_cursor) do
50
+ StoragePage = Data.define(:objects, :next_cursor)
51
+
52
+ # Freezes a page of storage objects and its cursor.
53
+ class StoragePage
54
+ # @dynamic objects, next_cursor, members, with, to_h, deconstruct, deconstruct_keys
55
+ # @dynamic self.[], self.members
44
56
  def initialize(objects:, next_cursor: nil)
45
- cursor = next_cursor.nil? ? nil : next_cursor.dup.freeze
46
- super(objects: objects.to_a.dup.freeze, next_cursor: cursor)
57
+ objects = objects.to_a.dup.freeze
58
+ next_cursor = next_cursor.nil? ? nil : next_cursor.dup.freeze
59
+ super
47
60
  end
48
61
  end
49
62
  end
@@ -11,7 +11,9 @@ module Volcano
11
11
  end
12
12
 
13
13
  def move(from_path, to_path)
14
- source, destination = storage_paths([from_path, to_path])
14
+ paths = storage_paths([from_path, to_path])
15
+ source = paths.fetch(0)
16
+ destination = paths.fetch(1)
15
17
  response = storage_request do |token|
16
18
  @transport.move_storage_object(
17
19
  authorization: token,
@@ -24,7 +26,9 @@ module Volcano
24
26
  end
25
27
 
26
28
  def copy(from_path, to_path)
27
- source, destination = storage_paths([from_path, to_path])
29
+ paths = storage_paths([from_path, to_path])
30
+ source = paths.fetch(0)
31
+ destination = paths.fetch(1)
28
32
  response = storage_request do |token|
29
33
  @transport.copy_storage_object(
30
34
  authorization: token,
@@ -75,7 +79,8 @@ module Volcano
75
79
  end
76
80
 
77
81
  def visibility_value(value)
78
- return value if [true, false].include?(value)
82
+ return true if value == true
83
+ return false if value == false
79
84
 
80
85
  raise ArgumentError, 'public must be true or false'
81
86
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Volcano
4
+ # Validates object metadata and paginated storage responses.
5
+ module StorageObjectResponse
6
+ include StorageResponse
7
+
8
+ private
9
+
10
+ def storage_object(payload)
11
+ payload = storage_payload(payload)
12
+ required = storage_object_required(payload)
13
+ optional = storage_object_optional(payload)
14
+ StorageObject.new(
15
+ id: required.fetch(0), bucket_id: required.fetch(1), name: required.fetch(2),
16
+ size: required.fetch(3), mime_type: required.fetch(4), is_public: required.fetch(5),
17
+ owner_id: optional.fetch(0), etag: optional.fetch(1), metadata: optional.fetch(2),
18
+ created_at: optional.fetch(3), updated_at: optional.fetch(4), public_url: optional.fetch(5)
19
+ )
20
+ end
21
+
22
+ def storage_object_required(payload)
23
+ [
24
+ storage_string(payload.fetch('id')), storage_string(payload.fetch('bucket_id')),
25
+ storage_string(payload.fetch('name')), storage_integer(payload.fetch('size')),
26
+ storage_string(payload.fetch('mime_type')), storage_boolean(payload.fetch('is_public'))
27
+ ]
28
+ end
29
+
30
+ def storage_object_optional(payload)
31
+ [
32
+ storage_optional_string(payload['owner_id']), storage_optional_string(payload['etag']),
33
+ storage_optional_metadata(payload['metadata']), storage_optional_time(payload['created_at']),
34
+ storage_optional_time(payload['updated_at']), storage_optional_string(payload['public_url'])
35
+ ]
36
+ end
37
+
38
+ def storage_object_list(value)
39
+ raise Error::TransportError, 'invalid storage objects' unless value.is_a?(Array)
40
+
41
+ value.map { |object| storage_object(object) }
42
+ end
43
+
44
+ def storage_cursor(value)
45
+ return if value.nil? || value == ''
46
+
47
+ storage_string(value)
48
+ end
49
+
50
+ def storage_boolean(value)
51
+ return true if value == true
52
+ return false if value == false
53
+
54
+ raise Error::TransportError, 'invalid storage boolean'
55
+ end
56
+
57
+ def storage_optional_string(value)
58
+ value.nil? ? nil : storage_string(value)
59
+ end
60
+
61
+ def storage_optional_time(value)
62
+ value.nil? ? nil : storage_time(value)
63
+ end
64
+
65
+ def storage_optional_metadata(value)
66
+ value.nil? ? nil : storage_json_hash(value)
67
+ end
68
+
69
+ def storage_json_hash(value)
70
+ raise Error::TransportError, 'invalid storage JSON object' unless value.is_a?(Hash)
71
+
72
+ value.to_h do |key, item|
73
+ raise Error::TransportError, 'invalid storage JSON key' unless key.is_a?(String)
74
+
75
+ [key, storage_json_value(item)]
76
+ end
77
+ end
78
+
79
+ def storage_json_value(value)
80
+ case value
81
+ when NilClass, TrueClass, FalseClass, Integer, Float, String then value
82
+ when Array then value.map { |item| storage_json_value(item) }
83
+ when Hash then storage_json_hash(value)
84
+ else raise Error::TransportError, 'invalid storage JSON value'
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Volcano
4
+ # Decodes resumable-upload responses before creating public records.
5
+ module StorageResponse
6
+ private
7
+
8
+ def upload_session(payload)
9
+ payload = storage_payload(payload)
10
+ UploadSession.new(
11
+ session_id: storage_string(payload.fetch('session_id')),
12
+ part_size: storage_integer(payload.fetch('part_size')),
13
+ total_parts: storage_integer(payload.fetch('total_parts')),
14
+ expires_at: storage_time(payload.fetch('expires_at'))
15
+ )
16
+ end
17
+
18
+ def upload_part_metadata(payload)
19
+ payload = storage_payload(payload)
20
+ UploadPart.new(
21
+ part_number: storage_integer(payload.fetch('part_number')),
22
+ etag: storage_string(payload.fetch('etag')),
23
+ size: storage_integer(payload.fetch('size'))
24
+ )
25
+ end
26
+
27
+ def upload_session_status(payload)
28
+ payload = storage_payload(payload)
29
+ text = storage_status_text(payload)
30
+ counts = storage_status_counts(payload)
31
+ storage_status_record(payload, text, counts)
32
+ end
33
+
34
+ def storage_status_record(payload, text, counts)
35
+ UploadSessionStatus.new(
36
+ session_id: text.fetch(0), status: text.fetch(1), path: text.fetch(2),
37
+ content_type: text.fetch(3), total_size: counts.fetch(0), part_size: counts.fetch(1),
38
+ total_parts: counts.fetch(2), parts_uploaded: counts.fetch(3),
39
+ bytes_uploaded: counts.fetch(4), parts: storage_parts(payload.fetch('parts', [])),
40
+ expires_at: storage_time(payload.fetch('expires_at')),
41
+ created_at: storage_time(payload.fetch('created_at'))
42
+ )
43
+ end
44
+
45
+ def storage_status_text(payload)
46
+ [
47
+ storage_string(payload.fetch('session_id')), storage_string(payload.fetch('status')),
48
+ storage_string(payload.fetch('path')), storage_string(payload.fetch('content_type'))
49
+ ]
50
+ end
51
+
52
+ def storage_status_counts(payload)
53
+ [
54
+ storage_integer(payload.fetch('total_size')), storage_integer(payload.fetch('part_size')),
55
+ storage_integer(payload.fetch('total_parts')), storage_integer(payload.fetch('parts_uploaded')),
56
+ storage_integer(payload.fetch('bytes_uploaded'))
57
+ ]
58
+ end
59
+
60
+ def storage_parts(value)
61
+ raise Error::TransportError, 'invalid upload parts' unless value.is_a?(Array)
62
+
63
+ value.map { |part| upload_part_metadata(part) }
64
+ end
65
+
66
+ def storage_payload(value)
67
+ raise Error::TransportError, 'invalid storage response' unless value.is_a?(Hash)
68
+ raise Error::TransportError, 'invalid storage response key' unless value.keys.all?(String)
69
+
70
+ value
71
+ end
72
+
73
+ def storage_string(value)
74
+ raise Error::TransportError, 'invalid storage string' unless value.is_a?(String)
75
+
76
+ value
77
+ end
78
+
79
+ def storage_integer(value)
80
+ raise Error::TransportError, 'invalid storage integer' unless value.is_a?(Integer)
81
+
82
+ value
83
+ end
84
+
85
+ def storage_time(value)
86
+ return value if value.is_a?(Time)
87
+ return Time.iso8601(value) if value.is_a?(String)
88
+
89
+ raise Error::TransportError, 'invalid storage time'
90
+ end
91
+ end
92
+ end