volcano-sdk 0.29.0 → 0.31.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 +10 -47
  3. data/lib/volcano/auth_email_change.rb +1 -1
  4. data/lib/volcano/auth_get_user.rb +36 -77
  5. data/lib/volcano/auth_notification_dispatch.rb +16 -0
  6. data/lib/volcano/auth_oauth.rb +4 -0
  7. data/lib/volcano/auth_oauth_api.rb +8 -3
  8. data/lib/volcano/auth_oauth_sign_in.rb +1 -1
  9. data/lib/volcano/auth_oauth_token.rb +2 -0
  10. data/lib/volcano/auth_profile_fields.rb +98 -0
  11. data/lib/volcano/auth_refresh.rb +29 -32
  12. data/lib/volcano/auth_session_binding.rb +25 -0
  13. data/lib/volcano/auth_session_construction.rb +67 -0
  14. data/lib/volcano/auth_sessions.rb +9 -45
  15. data/lib/volcano/auth_sign_out.rb +22 -14
  16. data/lib/volcano/auth_sign_up.rb +2 -0
  17. data/lib/volcano/auth_state.rb +3 -2
  18. data/lib/volcano/auth_state_notifications.rb +7 -4
  19. data/lib/volcano/auth_update_user.rb +1 -1
  20. data/lib/volcano/session.rb +0 -73
  21. data/lib/volcano/session_credentials.rb +82 -0
  22. data/lib/volcano/session_operations.rb +13 -6
  23. data/lib/volcano/session_page_mapping.rb +89 -0
  24. data/lib/volcano/storage.rb +18 -34
  25. data/lib/volcano/storage_models.rb +18 -5
  26. data/lib/volcano/storage_mutations.rb +8 -3
  27. data/lib/volcano/storage_object_response.rb +88 -0
  28. data/lib/volcano/storage_response.rb +92 -0
  29. data/lib/volcano/storage_resumable_upload.rb +13 -2
  30. data/lib/volcano/storage_session_adapter.rb +61 -0
  31. data/lib/volcano/storage_transport_adapter.rb +73 -0
  32. data/lib/volcano/storage_upload_models.rb +51 -27
  33. data/lib/volcano/storage_upload_sessions.rb +1 -35
  34. data/lib/volcano/version.rb +1 -1
  35. data/lib/volcano.rb +18 -7
  36. data/sig/auth_credentials.rbs +15 -0
  37. data/sig/auth_oauth.rbs +16 -0
  38. data/sig/auth_profile.rbs +10 -0
  39. data/sig/auth_sessions.rbs +7 -0
  40. data/sig/auth_subscription.rbs +6 -0
  41. data/sig/session.rbs +1 -1
  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 +17 -1
@@ -1,49 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require_relative 'session_page_mapping'
4
4
 
5
5
  module Volcano
6
- # Maps the internal wire response to immutable public session values.
7
- module SessionPageMapping
8
- SESSION_FIELDS = %i[
9
- id user_id provider expires_at is_active is_current user_agent ip_address last_ip_address
10
- last_activity_at session_started_at created_at updated_at
11
- ].freeze
12
- REQUIRED_SESSION_FIELDS = SESSION_FIELDS.first(6).freeze
13
-
14
- private
15
-
16
- def session_page(body)
17
- raise TypeError, 'Expected a complete session page' unless body.is_a?(Hash)
18
-
19
- sessions = body.fetch('sessions')
20
- raise TypeError, 'Expected a complete session page' unless sessions.is_a?(Array)
21
-
22
- SessionPage.new(
23
- sessions: sessions.map { |attributes| auth_session(attributes) },
24
- total: body.fetch('total'), page: body.fetch('page'), limit: body.fetch('limit'),
25
- total_pages: body.fetch('total_pages')
26
- )
27
- rescue KeyError
28
- raise TypeError, 'Expected a complete session page'
29
- end
30
-
31
- def auth_session(attributes)
32
- raise TypeError, 'Expected a complete authentication session' unless attributes.is_a?(Hash)
33
-
34
- required = REQUIRED_SESSION_FIELDS.to_h { |name| [name, attributes.fetch(name.to_s)] }
35
- optional = (SESSION_FIELDS - REQUIRED_SESSION_FIELDS).to_h do |name|
36
- [name, attributes[name.to_s]]
37
- end
38
- AuthSession.new(**required, **optional)
39
- rescue KeyError
40
- raise TypeError, 'Expected a complete authentication session'
41
- end
42
- end
43
- private_constant :SessionPageMapping
44
-
45
6
  # Multi-device session behavior for the authentication facade.
46
- class Auth
7
+ module AuthSessions
47
8
  include SessionPageMapping
48
9
 
49
10
  def list_sessions(page: 1, limit: 20)
@@ -57,10 +18,11 @@ module Volcano
57
18
 
58
19
  def delete_session(session_id)
59
20
  binding = @client.capture_session_binding
60
- raise Error::AuthenticationError, 'No active session' unless binding.last
21
+ session = binding.last
22
+ raise Error::AuthenticationError, 'No active session' unless session
61
23
 
62
24
  request_id = session_id.dup.freeze
63
- deletes_current = same_session_id?(binding.last.access_token, request_id)
25
+ deletes_current = same_session_id?(session.access_token, request_id)
64
26
  error = delete_bound_session_error(binding, request_id)
65
27
  current_unchanged = session_unchanged_after_deletion?(deletes_current, error, binding)
66
28
  raise Error::SessionChangedError, cause: error unless current_unchanged
@@ -119,12 +81,14 @@ module Volcano
119
81
 
120
82
  def deletion_binding_current?(binding, error)
121
83
  active = @client.capture_session_binding
122
- (active.last && active[1] == binding[1]) || (error && rejected_refresh?(binding, active))
84
+ return true if active.last && active[1] == binding[1]
85
+
86
+ !error.nil? && rejected_refresh?(binding, active)
123
87
  end
124
88
 
125
89
  def same_session_id?(access_token, session_id)
126
90
  current_session_id = access_token_session_id(access_token)
127
- session_id.is_a?(String) && current_session_id&.casecmp?(session_id)
91
+ session_id.is_a?(String) && current_session_id&.casecmp?(session_id) == true
128
92
  end
129
93
 
130
94
  def access_token_session_id(access_token)
@@ -2,24 +2,28 @@
2
2
 
3
3
  module Volcano
4
4
  # Revokes the captured server session and clears its local lineage.
5
- class Auth
5
+ module AuthSignOut
6
+ include AuthNotificationDispatch
7
+
6
8
  def sign_out
9
+ # @type var notifications: Array[Method]
10
+ notifications = []
7
11
  binding = @client.capture_session_binding
8
- return binding[1].wait_for_sign_out unless binding.last
12
+ session = binding.last
13
+ return binding[1].wait_for_sign_out unless session
9
14
 
10
- notifications = []
11
15
  binding[1].sign_out do |preceding, pending|
12
- sign_out_captured(binding, preceding, pending, notifications)
16
+ sign_out_captured(binding, session, preceding, pending, notifications)
13
17
  end
14
18
  ensure
15
- notifications&.each(&:call)
19
+ run_auth_notifications(notifications)
16
20
  end
17
21
 
18
22
  private
19
23
 
20
- def sign_out_captured(binding, preceding, pending, notifications)
24
+ def sign_out_captured(binding, session, preceding, pending, notifications)
21
25
  generation, owner, = binding
22
- current, refresh_error = revocation_credentials(binding, preceding)
26
+ current, refresh_error = revocation_credentials(owner, session, preceding)
23
27
  error = revocation_error(current, owner, pending ? refresh_error : nil, joined: pending)
24
28
  cleared = @client.clear_session_if_current?(generation, lineage: owner, notifications: notifications)
25
29
  raise Error::SessionChangedError, cause: error unless cleared
@@ -27,10 +31,13 @@ module Volcano
27
31
  raise error if error
28
32
  end
29
33
 
30
- def revocation_credentials(binding, preceding)
31
- [binding[1].result(preceding) || binding.last, nil]
34
+ def revocation_credentials(owner, session, preceding)
35
+ resolved = owner.result(preceding) || session
36
+ raise TypeError, 'Expected a session from refresh' unless resolved.is_a?(Session)
37
+
38
+ [resolved, nil]
32
39
  rescue Error::VolcanoError => e
33
- [binding.last, e]
40
+ [session, e]
34
41
  end
35
42
 
36
43
  def logout_response(refresh_token)
@@ -67,16 +74,17 @@ module Volcano
67
74
 
68
75
  def revoke_access_session(session, session_id, refresh_error, joined:)
69
76
  error = delete_session_error(session.access_token, session_id)
70
- return error unless refreshable_revocation?(error, session)
77
+ refresh_token = refreshable_revocation_token(error, session)
78
+ return error unless refresh_token
71
79
  return refresh_error || error if joined
72
80
 
73
- refreshed = parse_refresh_session(Transport.body(refresh_response(session.refresh_token), 200))
81
+ refreshed = parse_refresh_session(Transport.body(refresh_response(refresh_token), 200))
74
82
  SessionCredentials.validate_refresh(session, refreshed)
75
83
  delete_session_error(refreshed.access_token, session_id)
76
84
  end
77
85
 
78
- def refreshable_revocation?(error, session)
79
- error&.status == 401 && session.refresh_token
86
+ def refreshable_revocation_token(error, session)
87
+ session.refresh_token if error&.status == 401
80
88
  end
81
89
  end
82
90
  end
@@ -29,6 +29,8 @@ module Volcano
29
29
  end
30
30
 
31
31
  def build_sign_up_result(payload)
32
+ raise TypeError, INVALID_SIGN_UP_RESULT unless payload.is_a?(Hash)
33
+
32
34
  confirmation_required = payload.fetch('confirmation_required')
33
35
  message = payload.fetch('message')
34
36
  valid = [true, false].include?(confirmation_required) && message.is_a?(String)
@@ -81,9 +81,10 @@ module Volcano
81
81
 
82
82
  def update_user_if_current?(user, generation)
83
83
  @mutex.synchronize do
84
- return false unless generation == @generation && @session
84
+ current = @session
85
+ return false unless generation == @generation && current
85
86
 
86
- @session = SessionCredentials.with_user(@session, user)
87
+ @session = SessionCredentials.with_user(current, user)
87
88
  true
88
89
  end
89
90
  end
@@ -3,8 +3,8 @@
3
3
  module Volcano
4
4
  # Queues auth events under the state mutex and invokes subscribers outside it.
5
5
  module AuthStateNotifications
6
- CALLBACK_FAILURES = [Exception].freeze
7
- private_constant :CALLBACK_FAILURES
6
+ CALLBACK_FAILURE = Exception
7
+ private_constant :CALLBACK_FAILURE
8
8
 
9
9
  private
10
10
 
@@ -26,12 +26,14 @@ module Volcano
26
26
  end
27
27
 
28
28
  def drain_notifications
29
+ # @type var failure: Exception?
29
30
  failure = nil
30
31
  loop do
31
32
  notification = next_notification
32
33
  break unless notification
33
34
 
34
- current_failure = notify(*notification)
35
+ callback_ids, event, session = notification
36
+ current_failure = notify(callback_ids, event, session)
35
37
  failure ||= current_failure
36
38
  end
37
39
  raise failure if failure
@@ -48,12 +50,13 @@ module Volcano
48
50
  end
49
51
 
50
52
  def notify(callback_ids, event, session)
53
+ # @type var failure: Exception?
51
54
  failure = nil
52
55
  callback_ids.each do |callback_id|
53
56
  @mutex.synchronize { @callbacks[callback_id] }&.call(event, session)
54
57
  rescue StandardError => e
55
58
  Warning.warn("Volcano auth-state callback failed (#{e.class})\n")
56
- rescue *CALLBACK_FAILURES => e
59
+ rescue CALLBACK_FAILURE => e
57
60
  unsubscribe(callback_id)
58
61
  failure ||= e
59
62
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Volcano
4
4
  # Current-user update behavior for the authentication facade.
5
- class Auth
5
+ module AuthUpdateUser
6
6
  def update_user(password: nil, metadata: nil)
7
7
  profile_request do
8
8
  request_password = password&.dup&.freeze
@@ -9,79 +9,6 @@ module Volcano
9
9
  end
10
10
  private_constant :SESSION_USER_CODER
11
11
 
12
- # Owns credential snapshots and preserves validated identity during refresh.
13
- module SessionCredentials
14
- def self.build(access_token, refresh_token)
15
- raise ArgumentError, 'refresh_token requires access_token' if access_token.nil? && !refresh_token.nil?
16
- return if access_token.nil?
17
-
18
- Session.new(access_token: credential(access_token, :access_token),
19
- refresh_token: credential(refresh_token, :refresh_token))
20
- end
21
-
22
- def self.credential(value, name)
23
- return if value.nil?
24
-
25
- raise ArgumentError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
26
-
27
- value.dup.freeze
28
- end
29
- private_class_method :credential
30
-
31
- def self.validate_refresh_source(current, verified: false)
32
- return if verified || session_id(current.access_token)
33
-
34
- raise Error::AuthenticationError, 'Cannot refresh supplied credentials without a session identifier'
35
- end
36
-
37
- def self.validate_refresh(current, refreshed)
38
- return unless current
39
-
40
- validate_refresh_session_id(current, refreshed)
41
- return unless current.user_id && current.user_id != refreshed&.user_id
42
-
43
- raise Error::AuthenticationError, 'Refreshed session belongs to a different user'
44
- end
45
-
46
- def self.validate_refresh_session_id(current, refreshed)
47
- expected = session_id(current.access_token)
48
- return unless expected && expected != session_id(refreshed&.access_token)
49
-
50
- raise Error::AuthenticationError, 'Refreshed credentials belong to a different server session'
51
- end
52
- private_class_method :validate_refresh_session_id
53
-
54
- # Untrusted continuity constraint, never authenticated user identity.
55
- def self.session_id(access_token)
56
- payload = token_payload(access_token)
57
- return unless payload.is_a?(Hash)
58
-
59
- value = payload['session_id']
60
- value.downcase if value.is_a?(String) && value.match?(/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/i)
61
- end
62
-
63
- def self.token_payload(access_token)
64
- return unless access_token.is_a?(String)
65
-
66
- parts = access_token.split('.')
67
- return unless parts.length == 3
68
-
69
- encoded = parts.fetch(1).tr('-_', '+/')
70
- JSON.parse((encoded + ('=' * (-encoded.length % 4))).unpack1('m0'))
71
- rescue ArgumentError, JSON::ParserError
72
- nil
73
- end
74
- private_class_method :token_payload
75
-
76
- def self.with_user(session, user)
77
- user_id = (session.user_id || user.fetch('id')).dup.freeze
78
- raise Error::AuthenticationError, 'Profile belongs to a different user' unless user['id'] == user_id
79
-
80
- Session.new(**session.to_h, user_id: user_id, user: user)
81
- end
82
- end
83
- private_constant :SessionCredentials
84
-
85
12
  # Local credentials; refresh credentials and user identity may be unknown.
86
13
  Session = Data.define(:access_token, :refresh_token, :user_id, :user) do
87
14
  def initialize(access_token:, refresh_token: nil, user_id: nil, user: nil)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require_relative 'session'
6
+
7
+ module Volcano
8
+ # Owns credential snapshots and preserves validated identity during refresh.
9
+ module SessionCredentials
10
+ def self.build(access_token, refresh_token)
11
+ raise ArgumentError, 'refresh_token requires access_token' if access_token.nil? && !refresh_token.nil?
12
+ return if access_token.nil?
13
+
14
+ Session.new(access_token: credential(access_token, :access_token),
15
+ refresh_token: refresh_token.nil? ? nil : credential(refresh_token, :refresh_token))
16
+ end
17
+
18
+ def self.credential(value, name)
19
+ raise ArgumentError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
20
+
21
+ value.dup.freeze
22
+ end
23
+ private_class_method :credential
24
+
25
+ def self.validate_refresh_source(current, verified: false)
26
+ return if verified || session_id(current.access_token)
27
+
28
+ raise Error::AuthenticationError, 'Cannot refresh supplied credentials without a session identifier'
29
+ end
30
+
31
+ def self.validate_refresh(current, refreshed)
32
+ return unless current
33
+
34
+ validate_refresh_session_id(current, refreshed)
35
+ return unless current.user_id && current.user_id != refreshed&.user_id
36
+
37
+ raise Error::AuthenticationError, 'Refreshed session belongs to a different user'
38
+ end
39
+
40
+ def self.validate_refresh_session_id(current, refreshed)
41
+ expected = session_id(current.access_token)
42
+ return unless expected && expected != session_id(refreshed&.access_token)
43
+
44
+ raise Error::AuthenticationError, 'Refreshed credentials belong to a different server session'
45
+ end
46
+ private_class_method :validate_refresh_session_id
47
+
48
+ # Untrusted continuity constraint, never authenticated user identity.
49
+ def self.session_id(access_token)
50
+ payload = token_payload(access_token)
51
+ return unless payload.is_a?(Hash)
52
+
53
+ value = payload['session_id']
54
+ value.downcase if value.is_a?(String) && value.match?(/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/i)
55
+ end
56
+
57
+ def self.token_payload(access_token)
58
+ return unless access_token.is_a?(String)
59
+
60
+ parts = access_token.split('.')
61
+ return unless parts.length == 3
62
+
63
+ encoded = parts.fetch(1).tr('-_', '+/')
64
+ JSON.parse(Base64.strict_decode64(encoded + ('=' * (-encoded.length % 4))))
65
+ rescue ArgumentError, JSON::ParserError
66
+ nil
67
+ end
68
+ private_class_method :token_payload
69
+
70
+ def self.with_user(session, user)
71
+ user_id = session.user_id || user.fetch('id')
72
+ raise Error::AuthenticationError, 'Profile has no valid user identifier' unless user_id.is_a?(String)
73
+
74
+ user_id = user_id.dup.freeze
75
+ raise Error::AuthenticationError, 'Profile belongs to a different user' unless user['id'] == user_id
76
+
77
+ Session.new(access_token: session.access_token, refresh_token: session.refresh_token,
78
+ user_id: user_id, user: user)
79
+ end
80
+ end
81
+ private_constant :SessionCredentials
82
+ end
@@ -42,7 +42,9 @@ module Volcano
42
42
  def refresh(&)
43
43
  operation, owner = @mutex.synchronize do
44
44
  raise Error::SessionChangedError if @sign_out || @locally_cleared
45
- next [@refresh, false] if @refresh && !@refresh.done
45
+
46
+ current = @refresh
47
+ next [current, false] if current && !current.done
46
48
 
47
49
  @refresh = Outcome.new(false)
48
50
  [@refresh, true]
@@ -51,11 +53,7 @@ module Volcano
51
53
  end
52
54
 
53
55
  def sign_out
54
- operation, owner, preceding, pending = @mutex.synchronize do
55
- first = @sign_out.nil?
56
- @sign_out ||= Outcome.new(false)
57
- [@sign_out, first, @refresh, @refresh && !@refresh.done]
58
- end
56
+ operation, owner, preceding, pending = @mutex.synchronize { claim_sign_out }
59
57
  execute(operation, owner) do
60
58
  yield(preceding, pending)
61
59
  ensure
@@ -79,10 +77,19 @@ module Volcano
79
77
 
80
78
  private
81
79
 
80
+ def claim_sign_out
81
+ first = @sign_out.nil? == true
82
+ operation = @sign_out ||= Outcome.new(false)
83
+ current = @refresh
84
+ [operation, first, current, current && !current.done]
85
+ end
86
+
82
87
  def execute(operation, owner)
83
88
  return result(operation) unless owner
84
89
 
85
90
  completed = false
91
+ # @type var value: Object?
92
+ value = nil
86
93
  begin
87
94
  value = yield
88
95
  completed = true
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Volcano
4
+ # Maps the internal wire response to immutable public session values.
5
+ module SessionPageMapping
6
+ private
7
+
8
+ def session_page(body)
9
+ raise TypeError, 'Expected a complete session page' unless body.is_a?(Hash)
10
+
11
+ sessions = body.fetch('sessions')
12
+ raise TypeError, 'Expected a complete session page' unless sessions.is_a?(Array)
13
+
14
+ SessionPage.new(
15
+ sessions: sessions.map { |attributes| auth_session(attributes) },
16
+ total: required_integer(body, 'total'), page: required_integer(body, 'page'),
17
+ limit: required_integer(body, 'limit'), total_pages: required_integer(body, 'total_pages')
18
+ )
19
+ rescue KeyError
20
+ raise TypeError, 'Expected a complete session page'
21
+ end
22
+
23
+ def auth_session(attributes)
24
+ raise TypeError, 'Expected a complete authentication session' unless attributes.is_a?(Hash)
25
+
26
+ build_auth_session(attributes)
27
+ rescue KeyError
28
+ raise TypeError, 'Expected a complete authentication session'
29
+ end
30
+
31
+ def build_auth_session(attributes)
32
+ AuthSession.new(
33
+ id: required_string(attributes, 'id'), user_id: required_string(attributes, 'user_id'),
34
+ provider: required_string(attributes, 'provider'), expires_at: required_time(attributes, 'expires_at'),
35
+ is_active: required_boolean(attributes, 'is_active'), is_current: required_boolean(attributes, 'is_current'),
36
+ user_agent: optional_string(attributes, 'user_agent'), ip_address: optional_string(attributes, 'ip_address'),
37
+ last_ip_address: optional_string(attributes, 'last_ip_address'),
38
+ last_activity_at: optional_time(attributes, 'last_activity_at'),
39
+ session_started_at: optional_time(attributes, 'session_started_at'),
40
+ created_at: optional_time(attributes, 'created_at'), updated_at: optional_time(attributes, 'updated_at')
41
+ )
42
+ end
43
+
44
+ def required_integer(body, name)
45
+ value = body.fetch(name)
46
+ raise TypeError, 'Expected a complete session page' unless value.is_a?(Integer)
47
+
48
+ value
49
+ end
50
+
51
+ def required_string(body, name)
52
+ value = body.fetch(name)
53
+ raise TypeError, 'Expected a complete authentication session' unless value.is_a?(String)
54
+
55
+ value
56
+ end
57
+
58
+ def required_time(body, name)
59
+ value = body.fetch(name)
60
+ raise TypeError, 'Expected a complete authentication session' unless value.is_a?(Time)
61
+
62
+ value
63
+ end
64
+
65
+ def required_boolean(body, name)
66
+ value = body.fetch(name)
67
+ unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
68
+ raise TypeError, 'Expected a complete authentication session'
69
+ end
70
+
71
+ value
72
+ end
73
+
74
+ def optional_string(body, name)
75
+ value = body[name]
76
+ raise TypeError, 'Expected a complete authentication session' unless value.nil? || value.is_a?(String)
77
+
78
+ value
79
+ end
80
+
81
+ def optional_time(body, name)
82
+ value = body[name]
83
+ raise TypeError, 'Expected a complete authentication session' unless value.nil? || value.is_a?(Time)
84
+
85
+ value
86
+ end
87
+ end
88
+ private_constant :SessionPageMapping
89
+ end
@@ -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