volcano-sdk 0.31.0 → 0.32.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -7
  3. data/lib/volcano/database.rb +28 -37
  4. data/lib/volcano/durable.rb +6 -6
  5. data/lib/volcano/durable_models.rb +65 -22
  6. data/lib/volcano/function_auth.rb +9 -2
  7. data/lib/volcano/functions.rb +18 -15
  8. data/lib/volcano/immutable_request_value.rb +21 -0
  9. data/lib/volcano/logs.rb +14 -9
  10. data/lib/volcano/models.rb +55 -22
  11. data/lib/volcano/realtime/broadcast_delivery.rb +26 -2
  12. data/lib/volcano/realtime/channel_callbacks.rb +23 -15
  13. data/lib/volcano/realtime/channel_lifecycle.rb +9 -9
  14. data/lib/volcano/realtime/connection.rb +9 -5
  15. data/lib/volcano/realtime/connection_callbacks.rb +5 -1
  16. data/lib/volcano/realtime/lifecycle.rb +2 -1
  17. data/lib/volcano/realtime/postgres_batch.rb +11 -3
  18. data/lib/volcano/realtime/postgres_changes.rb +33 -16
  19. data/lib/volcano/realtime/postgres_database.rb +14 -1
  20. data/lib/volcano/realtime/postgres_delivery.rb +6 -1
  21. data/lib/volcano/realtime/postgres_expansion.rb +10 -4
  22. data/lib/volcano/realtime/presence.rb +18 -4
  23. data/lib/volcano/realtime/presence_state.rb +23 -6
  24. data/lib/volcano/realtime/protocol.rb +14 -9
  25. data/lib/volcano/realtime/protocol_dispatch.rb +25 -7
  26. data/lib/volcano/realtime/protocol_io.rb +8 -4
  27. data/lib/volcano/realtime/protocol_lifecycle.rb +2 -0
  28. data/lib/volcano/realtime/protocol_publication_dispatch.rb +12 -10
  29. data/lib/volcano/realtime/protocol_recovery.rb +7 -3
  30. data/lib/volcano/realtime/protocol_recovery_dispatch.rb +2 -0
  31. data/lib/volcano/realtime/reconnect.rb +4 -1
  32. data/lib/volcano/realtime.rb +122 -99
  33. data/lib/volcano/session.rb +14 -3
  34. data/lib/volcano/transport.rb +53 -11
  35. data/lib/volcano/user.rb +12 -7
  36. data/lib/volcano/version.rb +1 -1
  37. data/lib/volcano.rb +1 -0
  38. data/sig/auth_session_records.rbs +1 -1
  39. data/sig/client.rbs +15 -0
  40. data/sig/core_facades.rbs +81 -0
  41. data/sig/durable_records.rbs +1 -1
  42. data/sig/locks.rbs +10 -0
  43. data/sig/realtime.rbs +45 -0
  44. data/sig/realtime_postgres_change.rbs +1 -2
  45. data/sig/user.rbs +1 -1
  46. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36cf6c142c98f421f086d919b4d5e83034e95831d2b4246a892be57ebfaea9d5
4
- data.tar.gz: 877909af108367683c4cbb434235baa27669af9d303dd0b52307919446f8c348
3
+ metadata.gz: b0f776d6ce8242c9e13c9bda50c0a57656288bfe4ff5e14f88e75c19a7609ded
4
+ data.tar.gz: 25436052f65e6ededb49df00688a2e7d4076a233ca84502a2503dc470d1ab6d5
5
5
  SHA512:
6
- metadata.gz: 832893a1b1ba6caa88f144b81d8b8e155d2041be82fccffe4328eeb1f59f4fc8e3ce84adbb476f3b1a6b26ed719da7293f9dfb6a563270cdcb5222125661f378
7
- data.tar.gz: acfb489b98c3e8dcca7918ee1136d4e04f62fec2a7287fcc16fcd952edd3bfae2d4973b6307ef53d577a08cb4104511666917e4435c3d7722bb3cde544d7ad3d
6
+ metadata.gz: 0da1887ccafe797e2169899402489b9eab52d6587782461fd0390dfc63041ebe0a134e6ab89dc42d9ab5e5acf2dae34332516b2727f4bc51726f078319d58205
7
+ data.tar.gz: fce88e25d637dfb1dda8bb10b279e0dbfcbe621e4912ca1c941460eb1e94b6cc4b1e84242402b692ba19a66c0cab89992ea14d826beef3710629970dd361353f
data/README.md CHANGED
@@ -505,26 +505,32 @@ handle = client.durable.start(
505
505
  execution_name: "order-9"
506
506
  )
507
507
 
508
- execution = client.durable.get(project_id, "charge-order", handle.id)
508
+ owner_client = Volcano::Client.new(
509
+ api_url: "https://api.volcano.dev",
510
+ anon_key: ENV.fetch("VOLCANO_ANON_KEY"),
511
+ access_token: ENV.fetch("VOLCANO_PLATFORM_TOKEN")
512
+ )
513
+ execution = owner_client.durable.get(project_id, "charge-order", handle.id)
509
514
  puts [execution.status, execution.terminal?, execution.result]
510
515
 
511
- page = client.durable.list(project_id, "charge-order", status: "running")
516
+ page = owner_client.durable.list(project_id, "charge-order", status: "running")
512
517
  puts [page.total, page.has_more]
513
518
 
514
- client.durable.stop(project_id, "charge-order", handle.id)
519
+ owner_client.durable.stop(project_id, "charge-order", handle.id)
515
520
  ```
516
521
 
517
522
  `start` takes a durable function's name or its id and returns a handle, never a
518
- result: an execution can run for hours, so its result is read back with `get`.
523
+ result: an execution can run for up to 366 days, so its result is read back with `get`.
519
524
  It uses the same credential `invoke` does — the active user session, then a
520
525
  configured service key, then the anonymous key — and is the only durable
521
526
  operation an application credential may perform. An `execution_name` makes the
522
527
  start idempotent: starting again under the same name returns the execution that
523
528
  already exists rather than beginning a second one.
524
529
 
525
- `get`, `list`, and `stop` are owner-scoped and require a platform token or a
526
- configured service key, because an execution is addressed by its id alone. An
527
- auth-user session from sign-in is not accepted. `get` carries the deeply
530
+ `get`, `list`, and `stop` are owner-scoped and require the project owner's
531
+ platform user token, because an execution is addressed by its id alone.
532
+ Auth-user sessions, anonymous keys, service keys, and project access tokens are
533
+ not accepted. `get` carries the deeply
528
534
  frozen `result` once the execution has succeeded, and `error` when it failed;
529
535
  `result_expired` separates a result the platform has discarded from a function
530
536
  that returned nothing. `terminal?` reports whether the execution has stopped
@@ -1,33 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Ruby SDK runtime and immutable database builders.
3
4
  module Volcano
4
- # Captures caller-owned query values before storing them in immutable builders.
5
- module ImmutableQueryValue
6
- module_function
7
-
8
- def capture(value)
9
- case value
10
- when String then value.dup.freeze
11
- when Array then value.map { |item| capture(item) }.freeze
12
- when Hash then capture_hash(value)
13
- else value
14
- end
15
- end
16
-
17
- def capture_hash(value)
18
- value.to_h { |key, item| [capture(key), capture(item)] }.freeze
19
- end
20
- end
21
- private_constant :ImmutableQueryValue
22
-
23
5
  QueryContext = Data.define(:client, :transport, :database_name)
24
6
  private_constant :QueryContext
25
7
  QueryState = Data.define(:columns, :filters, :order, :limit, :offset)
26
8
  private_constant :QueryState
9
+ # @type var empty_columns: Array[String]
10
+ empty_columns = []
11
+ empty_columns.freeze
12
+ # @type var empty_conditions: Array[query_condition]
13
+ empty_conditions = []
14
+ empty_conditions.freeze
27
15
  EMPTY_QUERY_STATE = QueryState.new(
28
- columns: [].freeze,
29
- filters: [].freeze,
30
- order: [].freeze,
16
+ columns: empty_columns,
17
+ filters: empty_conditions,
18
+ order: empty_conditions,
31
19
  limit: nil,
32
20
  offset: nil
33
21
  )
@@ -35,6 +23,7 @@ module Volcano
35
23
 
36
24
  # Adds the shared immutable filter vocabulary to database builders.
37
25
  module FilterMethods
26
+ # @dynamic copy
38
27
  def eq(column, value) = add_filter(column, 'eq', value)
39
28
  def neq(column, value) = add_filter(column, 'neq', value)
40
29
  def gt(column, value) = add_filter(column, 'gt', value)
@@ -50,9 +39,9 @@ module Volcano
50
39
 
51
40
  def add_filter(column, operator, value)
52
41
  condition = {
53
- 'column' => ImmutableQueryValue.capture(column),
42
+ 'column' => ImmutableRequestValue.capture(column),
54
43
  'operator' => operator,
55
- 'value' => ImmutableQueryValue.capture(value)
44
+ 'value' => ImmutableRequestValue.capture(value)
56
45
  }.freeze
57
46
  copy(filters: [*@filters, condition])
58
47
  end
@@ -62,7 +51,7 @@ module Volcano
62
51
  # Creates immutable queries scoped to one project database.
63
52
  class Database
64
53
  def initialize(client, transport, name)
65
- database_name = ImmutableQueryValue.capture(name)
54
+ database_name = ImmutableRequestValue.capture(name)
66
55
  @context = QueryContext.new(client:, transport:, database_name:)
67
56
  end
68
57
 
@@ -77,7 +66,7 @@ module Volcano
77
66
 
78
67
  def initialize(context, table, state: EMPTY_QUERY_STATE)
79
68
  @context = context
80
- @table = ImmutableQueryValue.capture(table)
69
+ @table = ImmutableRequestValue.capture(table)
81
70
  @columns = state.columns
82
71
  @filters = state.filters
83
72
  @order = state.order
@@ -87,13 +76,13 @@ module Volcano
87
76
  end
88
77
 
89
78
  def select(*columns)
90
- copy(columns: ImmutableQueryValue.capture(columns))
79
+ copy(columns: ImmutableRequestValue.capture(columns))
91
80
  end
92
81
 
93
- def insert(values) = InsertBuilder.new(@context, @table, ImmutableQueryValue.capture(values))
82
+ def insert(values) = InsertBuilder.new(@context, @table, ImmutableRequestValue.capture(values))
94
83
 
95
84
  def update(values)
96
- UpdateBuilder.new(@context, @table, ImmutableQueryValue.capture(values), filters: @filters)
85
+ UpdateBuilder.new(@context, @table, ImmutableRequestValue.capture(values), filters: @filters)
97
86
  end
98
87
 
99
88
  def delete
@@ -101,7 +90,7 @@ module Volcano
101
90
  end
102
91
 
103
92
  def order(column, ascending: true)
104
- clause = { 'column' => ImmutableQueryValue.capture(column), 'ascending' => ascending }.freeze
93
+ clause = { 'column' => ImmutableRequestValue.capture(column), 'ascending' => ascending }.freeze
105
94
  copy(order: [*@order, clause])
106
95
  end
107
96
 
@@ -123,7 +112,7 @@ module Volcano
123
112
  )
124
113
  end
125
114
  end
126
- Transport.body(response, 200).fetch('data')
115
+ Transport.json_rows(Transport.body(response, 200))
127
116
  end
128
117
 
129
118
  private
@@ -149,7 +138,9 @@ module Volcano
149
138
  end
150
139
 
151
140
  def query_body
152
- { 'table' => @table }.tap do |body|
141
+ # @type var body: sdk_payload
142
+ body = { 'table' => @table }
143
+ body.tap do |body|
153
144
  add_query_selection(body)
154
145
  body['limit'] = @limit unless @limit.nil?
155
146
  body['offset'] = @offset unless @offset.nil?
@@ -184,7 +175,7 @@ module Volcano
184
175
  )
185
176
  end
186
177
  end
187
- Transport.body(response, 200).fetch('data')
178
+ Transport.json_rows(Transport.body(response, 200))
188
179
  end
189
180
  end
190
181
 
@@ -192,7 +183,7 @@ module Volcano
192
183
  class UpdateBuilder
193
184
  include FilterMethods
194
185
 
195
- def initialize(context, table, values, filters: [].freeze)
186
+ def initialize(context, table, values, filters: EMPTY_QUERY_STATE.filters)
196
187
  @context = context
197
188
  @table = table
198
189
  @values = values
@@ -210,7 +201,7 @@ module Volcano
210
201
  )
211
202
  end
212
203
  end
213
- Transport.body(response, 200).fetch('data')
204
+ Transport.json_rows(Transport.body(response, 200))
214
205
  end
215
206
 
216
207
  private
@@ -224,7 +215,7 @@ module Volcano
224
215
  class DeleteBuilder
225
216
  include FilterMethods
226
217
 
227
- def initialize(context, table, filters: [].freeze)
218
+ def initialize(context, table, filters: EMPTY_QUERY_STATE.filters)
228
219
  @context = context
229
220
  @table = table
230
221
  @filters = filters
@@ -241,7 +232,7 @@ module Volcano
241
232
  )
242
233
  end
243
234
  end
244
- Transport.body(response, 200).fetch('data')
235
+ Transport.json_rows(Transport.body(response, 200))
245
236
  end
246
237
 
247
238
  private
@@ -40,10 +40,10 @@ module Volcano
40
40
 
41
41
  # Reads an execution, including its result once it has succeeded.
42
42
  #
43
- # Owner-scoped: an execution is addressed by its id alone and an anon key
44
- # is held by everyone who loads the page, so this takes the project id and
45
- # a platform token or service key. An auth-user session from sign-in is
46
- # not enough. Poll it from a backend, not a browser.
43
+ # Owner-scoped: an execution is addressed by its id alone, so this takes
44
+ # the project id and the project owner's platform user token. Auth-user
45
+ # sessions, anon keys, service keys, and project access tokens are not
46
+ # accepted. Poll it from a backend, not a browser.
47
47
  def get(project_id, function_name, execution_id)
48
48
  project, function_id, execution = execution_path(project_id, function_name, execution_id)
49
49
  response = Transport.invoke do
@@ -100,7 +100,7 @@ module Volcano
100
100
  # An empty path segment would address the collection instead of the
101
101
  # execution, which is a different request rather than a failed one.
102
102
  def identifier(value, field)
103
- raise ArgumentError, "#{field} must be a non-empty String" unless present_string?(value)
103
+ raise ArgumentError, "#{field} must be a non-empty String" unless value.is_a?(String) && present_string?(value)
104
104
 
105
105
  value.strip.freeze
106
106
  end
@@ -128,7 +128,7 @@ module Volcano
128
128
  end
129
129
 
130
130
  def validate_page_limit(limit)
131
- return if limit.nil? || (positive_integer?(limit) && limit <= MAX_PAGE_SIZE)
131
+ return if limit.nil? || (limit.is_a?(Integer) && positive_integer?(limit) && limit <= MAX_PAGE_SIZE)
132
132
 
133
133
  raise ArgumentError, "limit must be an Integer between 1 and #{MAX_PAGE_SIZE}"
134
134
  end
@@ -15,9 +15,16 @@ module Volcano
15
15
  private_constant :DURABLE_TERMINAL_STATUSES
16
16
 
17
17
  # Why a failed or timed-out execution ended.
18
- DurableExecutionError = Data.define(:type, :message) do
18
+ DurableExecutionError = Data.define(:type, :message)
19
+
20
+ # Reopens the generated record for checked initialization.
21
+ class DurableExecutionError
22
+ # @dynamic type, message, members, with, to_h, deconstruct, deconstruct_keys
23
+ # @dynamic self.[], self.members
19
24
  def initialize(type: nil, message: nil)
20
- super(type: type&.dup&.freeze, message: message&.dup&.freeze)
25
+ type = type&.dup&.freeze
26
+ message = message&.dup&.freeze
27
+ super
21
28
  end
22
29
  end
23
30
 
@@ -27,7 +34,13 @@ module Volcano
27
34
  # once its retention lapses. That is not the same as a function that returned
28
35
  # nothing, so read `result_expired` before concluding anything from a missing
29
36
  # result.
30
- DurableExecution = Data.define(*DURABLE_EXECUTION_ATTRIBUTES) do
37
+ DurableExecution = Data.define(*DURABLE_EXECUTION_ATTRIBUTES)
38
+
39
+ # Reopens the generated record for checked initialization.
40
+ class DurableExecution
41
+ # @dynamic id, function_id, name, status, region, created_at, result, result_expired
42
+ # @dynamic error, completed_at, members, with, to_h, deconstruct, deconstruct_keys
43
+ # @dynamic self.[], self.members
31
44
  def initialize(**attributes)
32
45
  unknown = attributes.keys - DURABLE_EXECUTION_ATTRIBUTES
33
46
  raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
@@ -38,7 +51,8 @@ module Volcano
38
51
  values = DURABLE_EXECUTION_ATTRIBUTES.to_h do |name|
39
52
  [name, immutable_value(attributes[name])]
40
53
  end
41
- super(**values)
54
+ attributes = values
55
+ super
42
56
  end
43
57
 
44
58
  # Reports whether the execution has stopped changing.
@@ -64,12 +78,15 @@ module Volcano
64
78
  end
65
79
 
66
80
  # One page of a durable function's executions, most recent first.
67
- DurableExecutionPage = Data.define(:executions, :page, :limit, :total, :has_more) do
81
+ DurableExecutionPage = Data.define(:executions, :page, :limit, :total, :has_more)
82
+
83
+ # Reopens the generated record for checked initialization.
84
+ class DurableExecutionPage
85
+ # @dynamic executions, page, limit, total, has_more, members, with, to_h
86
+ # @dynamic deconstruct, deconstruct_keys, self.[], self.members
68
87
  def initialize(executions:, page:, limit:, total:, has_more:)
69
- super(
70
- executions: executions.to_a.dup.freeze,
71
- page: page, limit: limit, total: total, has_more: has_more
72
- )
88
+ executions = executions.to_a.dup.freeze
89
+ super
73
90
  end
74
91
  end
75
92
 
@@ -77,18 +94,17 @@ module Volcano
77
94
  module DurableResponses
78
95
  INCOMPLETE_EXECUTION = 'Expected a complete durable execution'
79
96
  INCOMPLETE_PAGE = 'Expected a complete durable execution page'
80
- EXECUTION_FIELDS = %w[id function_id name status region].freeze
81
- private_constant :INCOMPLETE_EXECUTION, :INCOMPLETE_PAGE, :EXECUTION_FIELDS
97
+ private_constant :INCOMPLETE_EXECUTION, :INCOMPLETE_PAGE
82
98
 
83
99
  private
84
100
 
85
101
  def durable_execution(payload)
86
102
  values = execution_payload(payload)
87
103
  DurableExecution.new(
88
- id: values.fetch('id'), function_id: values.fetch('function_id'),
89
- name: values.fetch('name'), status: values.fetch('status'),
90
- region: values.fetch('region'), created_at: parse_time(values.fetch('created_at')),
91
- result: values['result'], result_expired: values['result_expired'],
104
+ id: execution_text(values, 'id'), function_id: execution_text(values, 'function_id'),
105
+ name: execution_text(values, 'name'), status: execution_text(values, 'status'),
106
+ region: execution_text(values, 'region'), created_at: execution_time(values),
107
+ result: execution_result(values['result']), result_expired: execution_expired(values['result_expired']),
92
108
  error: execution_error(values['error']), completed_at: parse_time(values['completed_at'])
93
109
  )
94
110
  end
@@ -96,12 +112,36 @@ module Volcano
96
112
  def execution_payload(payload)
97
113
  raise TypeError, INCOMPLETE_EXECUTION unless payload.is_a?(Hash)
98
114
 
99
- complete = EXECUTION_FIELDS.all? { |field| present_string?(payload[field]) }
100
- raise TypeError, INCOMPLETE_EXECUTION unless complete && payload['created_at']
101
-
102
115
  payload
103
116
  end
104
117
 
118
+ def execution_text(values, field)
119
+ value = values[field]
120
+ raise TypeError, INCOMPLETE_EXECUTION unless value.is_a?(String) && !value.strip.empty?
121
+
122
+ value
123
+ end
124
+
125
+ def execution_time(values)
126
+ case (value = values['created_at'])
127
+ when String then Time.iso8601(value)
128
+ when Time then value
129
+ else raise TypeError, INCOMPLETE_EXECUTION
130
+ end
131
+ end
132
+
133
+ def execution_expired(value)
134
+ return value if value.nil? || value.is_a?(TrueClass) || value.is_a?(FalseClass)
135
+
136
+ raise TypeError, INCOMPLETE_EXECUTION
137
+ end
138
+
139
+ def execution_result(value)
140
+ Transport.json_value(value)
141
+ rescue TypeError
142
+ raise TypeError, INCOMPLETE_EXECUTION, cause: nil
143
+ end
144
+
105
145
  def execution_error(payload)
106
146
  return if payload.nil?
107
147
  raise TypeError, INCOMPLETE_EXECUTION unless payload.is_a?(Hash)
@@ -110,10 +150,10 @@ module Volcano
110
150
  end
111
151
 
112
152
  def durable_execution_page(payload)
113
- data, has_more = page_payload(payload)
153
+ values, data, has_more = page_payload(payload)
114
154
  DurableExecutionPage.new(
115
155
  executions: data.map { |entry| durable_execution(entry) }, has_more: has_more,
116
- page: count(payload['page']), limit: count(payload['limit']), total: count(payload['total'])
156
+ page: count(values['page']), limit: count(values['limit']), total: count(values['total'])
117
157
  )
118
158
  end
119
159
 
@@ -124,7 +164,7 @@ module Volcano
124
164
  has_more = payload['has_more']
125
165
  raise TypeError, INCOMPLETE_PAGE unless data.is_a?(Array) && [true, false].include?(has_more)
126
166
 
127
- [data, has_more]
167
+ [payload, data, has_more]
128
168
  end
129
169
 
130
170
  def count(value)
@@ -138,7 +178,10 @@ module Volcano
138
178
  end
139
179
 
140
180
  def parse_time(value)
141
- value.is_a?(String) ? Time.iso8601(value) : value
181
+ return Time.iso8601(value) if value.is_a?(String)
182
+ return value if value.nil? || value.is_a?(Time)
183
+
184
+ raise TypeError, INCOMPLETE_EXECUTION
142
185
  end
143
186
  end
144
187
  private_constant :DurableResponses
@@ -32,7 +32,7 @@ module Volcano
32
32
 
33
33
  def refresh(original)
34
34
  # Resolution has released its cache lock before refresh callbacks run.
35
- @client.auth.__send__(:refresh_captured_session, @binding)
35
+ @client.auth.__send__(:refresh_captured_session, active_binding)
36
36
  rescue Error::SessionChangedError
37
37
  raise
38
38
  rescue Error::VolcanoError
@@ -41,11 +41,18 @@ module Volcano
41
41
 
42
42
  def validate
43
43
  if @binding.last
44
- @client.auth.__send__(:validate_read_failure, @binding)
44
+ @client.auth.__send__(:validate_read_failure, active_binding)
45
45
  elsif @client.capture_session_binding[1] != @binding[1]
46
46
  raise Error::SessionChangedError
47
47
  end
48
48
  end
49
+
50
+ def active_binding
51
+ generation, owner, session = @binding
52
+ raise Error::SessionChangedError unless session
53
+
54
+ [generation, owner, session]
55
+ end
49
56
  end
50
57
  private_constant :FunctionAuth
51
58
  end
@@ -3,6 +3,9 @@
3
3
  require_relative 'function_auth'
4
4
 
5
5
  module Volcano
6
+ INVALID_FUNCTION_NAME = 'Function name must be DNS-safe: lowercase letters, numbers, and hyphens; 1-63 characters'
7
+ private_constant :INVALID_FUNCTION_NAME
8
+
6
9
  # Invokes deployed Volcano functions by name.
7
10
  class Functions
8
11
  FUNCTION_NAME = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/
@@ -21,7 +24,7 @@ module Volcano
21
24
  def invoke(name, payload = {})
22
25
  auth = FunctionAuth.new(@client)
23
26
  validate_invocation(name, payload)
24
- invoke_owned(auth, name.dup.freeze, ImmutableQueryValue.capture(payload))
27
+ invoke_owned(auth, name.dup.freeze, ImmutableRequestValue.capture(payload))
25
28
  end
26
29
 
27
30
  private
@@ -53,9 +56,7 @@ module Volcano
53
56
  # X-Volcano-Function-Invoked only after dispatch, so its absence is what
54
57
  # separates the two. X-Volcano-Version cannot: the server stamps it on every
55
58
  # response, including errors raised before the function is reached.
56
- def stale_mapping?(response)
57
- response.status == 404 && header(response.headers, FUNCTION_INVOKED_HEADER).nil?
58
- end
59
+ def stale_mapping?(response) = response.status == 404 && header(response.headers, FUNCTION_INVOKED_HEADER).nil?
59
60
 
60
61
  def invoke_resolved(authorization, resolution, payload)
61
62
  if resolution.invoke_url.nil?
@@ -77,8 +78,8 @@ module Volcano
77
78
  # Hold the name's lock across the round trip so concurrent callers wait
78
79
  # for one resolve instead of each opening their own.
79
80
  FunctionResolution.resolve_lock(@api_url, authorization, name).synchronize do
80
- cached = FunctionResolution.lookup(@api_url, authorization, name)
81
- next cached_resolution(cached) if cached
81
+ cached_under_lock = FunctionResolution.lookup(@api_url, authorization, name)
82
+ next cached_resolution(cached_under_lock) if cached_under_lock
82
83
 
83
84
  resolve_uncached(authorization, name)
84
85
  end
@@ -88,7 +89,7 @@ module Volcano
88
89
  resolved = Transport.invoke do
89
90
  @transport.resolve_function_for_invocation(authorization: authorization, name: name)
90
91
  end
91
- payload = Transport.body(resolved, 200)
92
+ payload = Transport.json_object(Transport.body(resolved, 200), message: 'Expected a complete function response')
92
93
  resolution = resolved_resolution(payload)
93
94
  FunctionResolution.store(@api_url, authorization, name, resolution, cache_ttl(payload))
94
95
  resolution
@@ -98,24 +99,26 @@ module Volcano
98
99
  end
99
100
 
100
101
  def cached_resolution(cached)
101
- cached.resolution || raise(FunctionResolution.error_for(cached.failure))
102
+ return cached.resolution if cached.resolution
103
+
104
+ failure = cached.failure || raise(TypeError, 'Expected a cached function resolution')
105
+ raise FunctionResolution.error_for(failure)
102
106
  end
103
107
 
104
108
  def validate_invocation(name, payload)
105
- unless name.is_a?(String) && FUNCTION_NAME.match?(name)
106
- raise ArgumentError,
107
- 'Function name must be DNS-safe: lowercase letters, numbers, and hyphens; 1-63 characters'
108
- end
109
+ raise ArgumentError, INVALID_FUNCTION_NAME unless name.is_a?(String) && FUNCTION_NAME.match?(name)
109
110
  raise TypeError, 'Function payload must be a Hash' unless payload.is_a?(Hash)
110
111
  end
111
112
 
112
113
  def resolved_resolution(payload)
113
- function_id = payload['function_id'] if payload.is_a?(Hash)
114
+ payload = Transport.json_object(payload)
115
+ function_id = payload['function_id']
114
116
  raise TypeError, 'Expected a complete function response' unless function_id.is_a?(String) && !function_id.empty?
115
117
 
116
118
  # Absent when the deployment serves no public invocation domain, as in
117
119
  # local development; the function is reached through the API instead.
118
- invoke_url = FunctionResolution.valid_invoke_url(payload['invoke_url'], @api_url)
120
+ candidate = payload['invoke_url']
121
+ invoke_url = FunctionResolution.valid_invoke_url(candidate.is_a?(String) ? candidate : nil, @api_url)
119
122
  FunctionResolution::Resolution.new(function_id: function_id, invoke_url: invoke_url)
120
123
  end
121
124
 
@@ -136,7 +139,7 @@ module Volcano
136
139
  Transport.body(response, 200) unless response.status.between?(200, 299) || dispatched
137
140
 
138
141
  FunctionResponse.new(
139
- data: response.body, status: response.status,
142
+ data: Transport.json_value(response.body), status: response.status,
140
143
  headers: response.headers || {}, version: version
141
144
  )
142
145
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby SDK runtime and immutable caller-owned request values.
4
+ module Volcano
5
+ # Copies JSON-like request values before storing them in builders or sending them.
6
+ module ImmutableRequestValue
7
+ def self.capture(value)
8
+ case value
9
+ when String, Time then value.dup.freeze
10
+ when Array then value.map { |item| capture(item) }.freeze
11
+ when Hash then capture_hash(value)
12
+ else value
13
+ end
14
+ end
15
+
16
+ def self.capture_hash(value)
17
+ value.to_h { |key, item| [capture(key), capture(item)] }.freeze
18
+ end
19
+ end
20
+ private_constant :ImmutableRequestValue
21
+ end
data/lib/volcano/logs.rb CHANGED
@@ -3,8 +3,6 @@
3
3
  module Volcano
4
4
  # Searches retained project logs and activity.
5
5
  class Logs
6
- include ImmutableLogJSON
7
-
8
6
  def initialize(client, transport)
9
7
  @client = client
10
8
  @transport = transport
@@ -46,26 +44,27 @@ module Volcano
46
44
  end
47
45
  raise TypeError, 'Log request must be a Hash' unless request.is_a?(Hash)
48
46
 
49
- [project_id.dup.freeze, immutable_json(request)]
47
+ [project_id.dup.freeze, ImmutableRequestValue.capture(request)]
50
48
  end
51
49
 
52
50
  def search_response(body)
53
- data = response_data(body)
51
+ body, data = response_data(body)
54
52
  limit = body['limit']
55
53
  has_more = body['has_more']
56
54
  next_cursor = body['next_cursor']
57
- unless limit.is_a?(Integer) && [true, false].include?(has_more) &&
58
- (next_cursor.nil? || next_cursor.is_a?(String))
55
+ unless limit.is_a?(Integer) && boolean?(has_more) && (next_cursor.nil? || next_cursor.is_a?(String))
59
56
  raise TypeError, 'Expected a complete log response'
60
57
  end
61
58
 
59
+ has_more = has_more == true
60
+
62
61
  LogSearchResponse.new(
63
62
  data: data, limit: limit, has_more: has_more, next_cursor: next_cursor
64
63
  )
65
64
  end
66
65
 
67
66
  def activity_response(body)
68
- data = response_data(body)
67
+ body, data = response_data(body)
69
68
  total = body['total']
70
69
  raise TypeError, 'Expected a complete log response' unless total.is_a?(Integer)
71
70
 
@@ -73,10 +72,16 @@ module Volcano
73
72
  end
74
73
 
75
74
  def response_data(body)
76
- data = body['data'] if body.is_a?(Hash)
75
+ raise TypeError, 'Expected a complete log response' unless body.is_a?(Hash)
76
+
77
+ data = body['data']
77
78
  raise TypeError, 'Expected a complete log response' unless data.is_a?(Array) && data.all?(Hash)
78
79
 
79
- data
80
+ [body, data]
81
+ end
82
+
83
+ def boolean?(value)
84
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
80
85
  end
81
86
  end
82
87
  end