busybee 0.1.0 → 0.3.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -7
  3. data/README.md +70 -42
  4. data/docs/client/quick_start.md +279 -0
  5. data/docs/client.md +825 -0
  6. data/docs/configuration.md +550 -0
  7. data/docs/grpc.md +50 -25
  8. data/docs/testing.md +118 -28
  9. data/docs/workers.md +982 -0
  10. data/exe/busybee +6 -0
  11. data/lib/busybee/cli.rb +173 -0
  12. data/lib/busybee/client/error_handling.rb +37 -0
  13. data/lib/busybee/client/job_operations.rb +236 -0
  14. data/lib/busybee/client/message_operations.rb +84 -0
  15. data/lib/busybee/client/process_operations.rb +108 -0
  16. data/lib/busybee/client/variable_operations.rb +64 -0
  17. data/lib/busybee/client.rb +87 -0
  18. data/lib/busybee/configure.rb +290 -0
  19. data/lib/busybee/credentials/camunda_cloud.rb +58 -0
  20. data/lib/busybee/credentials/insecure.rb +24 -0
  21. data/lib/busybee/credentials/oauth.rb +157 -0
  22. data/lib/busybee/credentials/tls.rb +43 -0
  23. data/lib/busybee/credentials.rb +200 -0
  24. data/lib/busybee/defaults.rb +20 -0
  25. data/lib/busybee/error.rb +50 -0
  26. data/lib/busybee/grpc/error.rb +60 -0
  27. data/lib/busybee/grpc.rb +2 -2
  28. data/lib/busybee/job.rb +219 -0
  29. data/lib/busybee/job_stream.rb +85 -0
  30. data/lib/busybee/logging.rb +61 -0
  31. data/lib/busybee/railtie.rb +113 -0
  32. data/lib/busybee/runner/hybrid.rb +64 -0
  33. data/lib/busybee/runner/multi.rb +101 -0
  34. data/lib/busybee/runner/polling.rb +54 -0
  35. data/lib/busybee/runner/streaming.rb +159 -0
  36. data/lib/busybee/runner.rb +97 -0
  37. data/lib/busybee/runtime_config.rb +184 -0
  38. data/lib/busybee/serialization.rb +100 -0
  39. data/lib/busybee/testing/activated_job.rb +33 -8
  40. data/lib/busybee/testing/helpers/execution.rb +139 -0
  41. data/lib/busybee/testing/helpers/support.rb +78 -0
  42. data/lib/busybee/testing/helpers.rb +56 -66
  43. data/lib/busybee/testing/matchers/complete_job.rb +55 -0
  44. data/lib/busybee/testing/matchers/fail_job.rb +75 -0
  45. data/lib/busybee/testing/matchers/have_activated.rb +1 -1
  46. data/lib/busybee/testing/matchers/have_available_jobs.rb +44 -0
  47. data/lib/busybee/testing/matchers/throw_bpmn_error_on.rb +72 -0
  48. data/lib/busybee/testing.rb +5 -33
  49. data/lib/busybee/version.rb +1 -1
  50. data/lib/busybee/worker/configuration.rb +287 -0
  51. data/lib/busybee/worker/dsl.rb +187 -0
  52. data/lib/busybee/worker/shutdown.rb +27 -0
  53. data/lib/busybee/worker.rb +130 -0
  54. data/lib/busybee.rb +134 -2
  55. metadata +80 -3
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/grpc"
4
+ require "busybee/serialization"
5
+
6
+ module Busybee
7
+ class Client
8
+ # Variable and incident operations for managing process instance state.
9
+ module VariableOperations
10
+ # Set variables on a process instance or element instance.
11
+ #
12
+ # @param element_instance_key [Integer, String] The element instance key
13
+ # (process instance key or service task key)
14
+ # @param vars [Hash] Variables to set
15
+ # @param local [Boolean] If true, variables are set only in the local scope
16
+ # (not propagated to parent scopes)
17
+ # @return [Integer] The variable set operation key
18
+ # @raise [ArgumentError] if vars is not a Hash
19
+ # @raise [Busybee::GRPC::Error] if setting variables fails
20
+ #
21
+ # @example Set variables on a process instance
22
+ # key = client.set_variables(process_instance_key, vars: { status: "approved" })
23
+ # # => 12345
24
+ #
25
+ # @example Set local variables on an element
26
+ # client.set_variables(element_key, vars: { tempData: "value" }, local: true)
27
+ #
28
+ def set_variables(element_instance_key, vars: {}, local: false)
29
+ raise ArgumentError, "vars must be a Hash" unless vars.is_a?(Hash)
30
+
31
+ request = Busybee::GRPC::SetVariablesRequest.new(
32
+ elementInstanceKey: element_instance_key.to_i,
33
+ variables: Busybee::Serialization.to_json(vars),
34
+ local: local
35
+ )
36
+
37
+ with_retry do
38
+ stub.set_variables(request).key
39
+ end
40
+ end
41
+
42
+ # Resolve an incident.
43
+ #
44
+ # @param incident_key [Integer, String] The incident key to resolve
45
+ # @return [Boolean] true if resolved
46
+ # @raise [Busybee::GRPC::Error] if resolving the incident fails
47
+ #
48
+ # @example Resolve an incident
49
+ # client.resolve_incident(54321)
50
+ # # => true
51
+ #
52
+ def resolve_incident(incident_key)
53
+ request = Busybee::GRPC::ResolveIncidentRequest.new(
54
+ incidentKey: incident_key.to_i
55
+ )
56
+
57
+ with_retry do
58
+ stub.resolve_incident(request)
59
+ true
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/duration"
5
+ require "busybee/credentials"
6
+ require "busybee/client/error_handling"
7
+ require "busybee/client/job_operations"
8
+ require "busybee/client/message_operations"
9
+ require "busybee/client/process_operations"
10
+ require "busybee/client/variable_operations"
11
+
12
+ module Busybee
13
+ # Ruby-idiomatic wrapper around Zeebe GRPC API.
14
+ #
15
+ # @example Basic usage with local Zeebe
16
+ # client = Busybee::Client.new(insecure: true)
17
+ # client.deploy_process("workflow.bpmn")
18
+ #
19
+ # @example With explicit credentials
20
+ # credentials = Busybee::Credentials::Insecure.new
21
+ # client = Busybee::Client.new(credentials)
22
+ #
23
+ # @example With gem-level configuration
24
+ # Busybee.credential_type = :insecure
25
+ # client = Busybee::Client.new
26
+ #
27
+ class Client
28
+ include ErrorHandling
29
+ include JobOperations
30
+ include MessageOperations
31
+ include ProcessOperations
32
+ include VariableOperations
33
+
34
+ attr_reader :credentials
35
+
36
+ # Create a new client.
37
+ #
38
+ # @param credentials [Credentials, nil] Explicit credentials object (first positional arg)
39
+ # @param params [Hash, nil] Credential kwargs (passed to Credentials.build if no explicit credentials)
40
+ # @raise [ArgumentError] if both credentials object and credential params are provided
41
+ #
42
+ # @example With credential parameters:
43
+ # Client.new(insecure: true, cluster_address: "localhost:26500")
44
+ #
45
+ # @example With explicit credentials:
46
+ # creds = Credentials::Insecure.new(cluster_address: "localhost:26500")
47
+ # Client.new(creds)
48
+ #
49
+ # @example Inferred entirely from gem configuration or env vars:
50
+ # Client.new
51
+ #
52
+ def initialize(credentials = nil, **params)
53
+ if credentials && params.any?
54
+ raise ArgumentError, "cannot pass both explicit credentials and credential parameters"
55
+ end
56
+
57
+ @credentials = if credentials
58
+ credentials
59
+ elsif params.empty? && Busybee.credentials.is_a?(Busybee::Credentials)
60
+ Busybee.credentials
61
+ else
62
+ Credentials.build(**params) # will attempt to autodetect from env if no params are given
63
+ end
64
+ end
65
+
66
+ # Returns the cluster address from credentials.
67
+ # @return [String] Cluster address (host:port)
68
+ def cluster_address
69
+ credentials.cluster_address
70
+ end
71
+
72
+ private
73
+
74
+ # Returns the GRPC stub for making API calls.
75
+ # @return [Busybee::GRPC::Gateway::Stub]
76
+ def stub
77
+ credentials.grpc_stub
78
+ end
79
+
80
+ # Ensures a value is in milliseconds.
81
+ # @param value [Integer, ActiveSupport::Duration] Value to convert
82
+ # @return [Integer] Value in milliseconds
83
+ def milliseconds_from(value)
84
+ value.is_a?(ActiveSupport::Duration) ? value.in_milliseconds.to_i : value.to_i
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Busybee
4
+ # Validated setters for gem-level configuration.
5
+ # Included into Busybee's singleton class; readers live in busybee.rb.
6
+ module Configure # rubocop:disable Metrics/ModuleLength
7
+ # --- String configs ---
8
+
9
+ def cluster_address=(value)
10
+ if value.nil?
11
+ @cluster_address = nil
12
+ return
13
+ end
14
+
15
+ validate_string!(:cluster_address, value)
16
+ @cluster_address = value.to_s
17
+ end
18
+
19
+ def worker_name=(value)
20
+ if value.nil?
21
+ @worker_name = nil
22
+ return
23
+ end
24
+
25
+ validate_string!(:worker_name, value)
26
+ @worker_name = value.to_s
27
+ end
28
+
29
+ # --- Boolean configs ---
30
+
31
+ def default_input_required=(value)
32
+ if value.nil?
33
+ @default_input_required = nil
34
+ return
35
+ end
36
+
37
+ validate_boolean!(:default_input_required, value)
38
+ @default_input_required = value
39
+ end
40
+
41
+ def default_max_jobs=(value)
42
+ @default_max_jobs = value.nil? ? nil : validate_positive_integer!(:default_max_jobs, value)
43
+ end
44
+
45
+ def default_output_required=(value)
46
+ if value.nil?
47
+ @default_output_required = nil
48
+ return
49
+ end
50
+
51
+ validate_boolean!(:default_output_required, value)
52
+ @default_output_required = value
53
+ end
54
+
55
+ def default_buffer=(value)
56
+ if value.nil?
57
+ @default_buffer = nil
58
+ return
59
+ end
60
+
61
+ validate_boolean!(:default_buffer, value)
62
+ @default_buffer = value
63
+ end
64
+
65
+ def grpc_retry_enabled=(value)
66
+ if value.nil?
67
+ @grpc_retry_enabled = nil
68
+ return
69
+ end
70
+
71
+ validate_boolean!(:grpc_retry_enabled, value)
72
+ @grpc_retry_enabled = value
73
+ end
74
+
75
+ # --- Duration configs (Integer ms or ActiveSupport::Duration) ---
76
+
77
+ def default_fail_job_backoff=(value)
78
+ @default_fail_job_backoff = value.nil? ? nil : validate_duration!(:default_fail_job_backoff, value)
79
+ end
80
+
81
+ def default_job_lock_timeout=(value)
82
+ @default_job_lock_timeout = value.nil? ? nil : validate_duration!(:default_job_lock_timeout, value)
83
+ end
84
+
85
+ def default_job_request_timeout=(value)
86
+ @default_job_request_timeout = value.nil? ? nil : validate_duration!(:default_job_request_timeout, value)
87
+ end
88
+
89
+ def default_message_ttl=(value)
90
+ @default_message_ttl = value.nil? ? nil : validate_duration!(:default_message_ttl, value)
91
+ end
92
+
93
+ def grpc_retry_delay_ms=(value)
94
+ @grpc_retry_delay_ms = value.nil? ? nil : validate_duration!(:grpc_retry_delay_ms, value)
95
+ end
96
+
97
+ def default_backpressure_delay=(value)
98
+ @default_backpressure_delay = value.nil? ? nil : validate_duration!(:default_backpressure_delay, value)
99
+ end
100
+
101
+ # --- Buffer throttle (three-state: false/nil = off, true → 0, Numeric = ms) ---
102
+
103
+ def default_buffer_throttle=(value)
104
+ @default_buffer_throttle = value.nil? ? nil : validate_buffer_throttle!(:default_buffer_throttle, value)
105
+ end
106
+
107
+ # --- Worker mode ---
108
+
109
+ def default_worker_mode=(value)
110
+ if value.nil?
111
+ @default_worker_mode = nil
112
+ return
113
+ end
114
+
115
+ validate_worker_mode!(:default_worker_mode, value)
116
+ @default_worker_mode = value.to_sym
117
+ end
118
+
119
+ # --- Error class list ---
120
+
121
+ def grpc_retry_errors=(value)
122
+ if value.nil?
123
+ @grpc_retry_errors = nil
124
+ return
125
+ end
126
+
127
+ validate_error_classes!(:grpc_retry_errors, value)
128
+ @grpc_retry_errors = value
129
+ end
130
+
131
+ # --- Shutdown errors (Array coercion, validates each is Exception subclass) ---
132
+
133
+ def shutdown_on_errors=(value)
134
+ coerced = Array(value)
135
+ coerced.each do |klass|
136
+ unless klass.is_a?(Class) && klass <= Exception
137
+ raise ArgumentError,
138
+ "shutdown_on_errors expects exception classes, got #{klass.inspect} (#{klass.class})"
139
+ end
140
+ end
141
+ @shutdown_on_errors = coerced
142
+ end
143
+
144
+ # --- Log format ---
145
+
146
+ def log_format=(value)
147
+ if value.nil?
148
+ @log_format = nil
149
+ return
150
+ end
151
+
152
+ str_value = value.to_s
153
+ if VALID_LOG_FORMATS.include?(str_value)
154
+ @log_format = str_value.to_sym
155
+ else
156
+ Logging.warn("Invalid log_format: #{str_value.inspect}. Valid formats: #{VALID_LOG_FORMATS.join(', ')}")
157
+ end
158
+ end
159
+
160
+ # --- Credential type ---
161
+
162
+ def credential_type=(value)
163
+ if value.nil?
164
+ @credential_type = nil
165
+ return
166
+ end
167
+
168
+ str_value = value.to_s
169
+ if VALID_CREDENTIAL_TYPES.include?(str_value)
170
+ @credential_type = str_value.to_sym
171
+ else
172
+ Logging.warn("Invalid credential_type: #{str_value.inspect}. Valid types: #{VALID_CREDENTIAL_TYPES.join(', ')}")
173
+ end
174
+ end
175
+
176
+ # --- Credentials object ---
177
+
178
+ def credentials=(value)
179
+ if value.nil?
180
+ @credentials = nil
181
+ return
182
+ end
183
+
184
+ unless value.is_a?(Busybee::Credentials)
185
+ raise ArgumentError, "credentials must be a Busybee::Credentials object, got #{value.class}"
186
+ end
187
+
188
+ @credentials = value
189
+ end
190
+
191
+ private
192
+
193
+ # Validates and coerces a duration config value.
194
+ # Returns the (possibly coerced) value to assign.
195
+ def validate_duration!(name, value) # rubocop:disable Metrics/AbcSize
196
+ return value if value.is_a?(Integer)
197
+ return value if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
198
+
199
+ if value.is_a?(String)
200
+ return value.to_f.to_i if value.match?(/\A\d+(\.\d+)?\z/)
201
+
202
+ raise ArgumentError,
203
+ "#{name} accepts Integer, ActiveSupport::Duration, or numeric String, " \
204
+ "got non-numeric String #{value.inspect}"
205
+ end
206
+
207
+ if value.is_a?(Numeric)
208
+ Logging.warn("#{name}: coercing #{value.class} #{value.inspect} to Integer #{value.to_i}")
209
+ return value.to_i
210
+ end
211
+
212
+ raise ArgumentError,
213
+ "#{name} accepts Integer, ActiveSupport::Duration, or numeric String, got #{value.class}"
214
+ end
215
+
216
+ def validate_boolean!(name, value)
217
+ return if [true, false].include?(value)
218
+
219
+ raise ArgumentError, "#{name} accepts true or false, got #{value.inspect} (#{value.class})"
220
+ end
221
+
222
+ def validate_positive_integer!(name, value)
223
+ if value.is_a?(Integer)
224
+ raise ArgumentError, "#{name} must be positive, got #{value}" unless value.positive?
225
+
226
+ return value
227
+ end
228
+
229
+ if value.is_a?(String) && value.match?(/\A\d+\z/)
230
+ int_value = value.to_i
231
+ raise ArgumentError, "#{name} must be positive, got #{int_value}" unless int_value.positive?
232
+
233
+ return int_value
234
+ end
235
+
236
+ raise ArgumentError,
237
+ "#{name} accepts a positive Integer or numeric String, got #{value.inspect} (#{value.class})"
238
+ end
239
+
240
+ def validate_string!(name, value)
241
+ return if value.is_a?(String) || value.is_a?(Symbol)
242
+
243
+ raise ArgumentError, "#{name} accepts String or Symbol, got #{value.inspect} (#{value.class})"
244
+ end
245
+
246
+ # Validates and coerces a buffer throttle value.
247
+ # Returns the (possibly coerced) value to assign.
248
+ def validate_buffer_throttle!(name, value)
249
+ return 0 if value == true
250
+ return false if value == false
251
+
252
+ if value.is_a?(Numeric)
253
+ raise ArgumentError, "#{name} must be non-negative, got #{value.inspect}" if value.negative?
254
+
255
+ return value
256
+ end
257
+
258
+ if value.is_a?(String)
259
+ return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
260
+
261
+ raise ArgumentError,
262
+ "#{name} accepts Numeric, boolean, or numeric String, got non-numeric String #{value.inspect}"
263
+ end
264
+
265
+ raise ArgumentError, "#{name} accepts Numeric, boolean, or numeric String, got #{value.inspect} (#{value.class})"
266
+ end
267
+
268
+ def validate_worker_mode!(name, value)
269
+ sym = value.to_sym
270
+ return if VALID_WORKER_MODES.include?(sym)
271
+
272
+ raise ArgumentError,
273
+ "#{name} must be one of #{VALID_WORKER_MODES.map(&:inspect).join(', ')}, got #{value.inspect}"
274
+ rescue NoMethodError
275
+ raise ArgumentError,
276
+ "#{name} accepts Symbol or String, got #{value.inspect} (#{value.class})"
277
+ end
278
+
279
+ def validate_error_classes!(name, value)
280
+ raise ArgumentError, "#{name} accepts an Array of exception classes, got #{value.class}" unless value.is_a?(Array)
281
+
282
+ value.each do |klass|
283
+ unless klass.is_a?(Class) && klass <= Exception
284
+ raise ArgumentError,
285
+ "#{name} expects exception classes, got #{klass.inspect} (#{klass.class})"
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/credentials/oauth"
4
+
5
+ module Busybee
6
+ class Credentials
7
+ # Camunda Cloud-specific OAuth credentials.
8
+ # Automatically derives the cluster address and OAuth configuration
9
+ # from cluster ID and region.
10
+ #
11
+ # @example Basic usage
12
+ # credentials = Busybee::Credentials::CamundaCloud.new(
13
+ # client_id: ENV["CAMUNDA_CLIENT_ID"],
14
+ # client_secret: ENV["CAMUNDA_CLIENT_SECRET"],
15
+ # cluster_id: ENV["CAMUNDA_CLUSTER_ID"],
16
+ # region: ENV["CAMUNDA_CLUSTER_REGION"]
17
+ # )
18
+ # stub = credentials.grpc_stub
19
+ #
20
+ # @example With scope for API access control
21
+ # credentials = Busybee::Credentials::CamundaCloud.new(
22
+ # client_id: ENV["CAMUNDA_CLIENT_ID"],
23
+ # client_secret: ENV["CAMUNDA_CLIENT_SECRET"],
24
+ # cluster_id: ENV["CAMUNDA_CLUSTER_ID"],
25
+ # region: ENV["CAMUNDA_CLUSTER_REGION"],
26
+ # scope: "Zeebe Tasklist Operate"
27
+ # )
28
+ #
29
+ class CamundaCloud < OAuth
30
+ CAMUNDA_AUTH_URL = "https://login.cloud.camunda.io/oauth/token"
31
+ CAMUNDA_AUDIENCE = "zeebe.camunda.io"
32
+
33
+ # @param client_id [String] Camunda Cloud client ID
34
+ # @param client_secret [String] Camunda Cloud client secret
35
+ # @param cluster_id [String] Camunda Cloud cluster ID
36
+ # @param region [String] Camunda Cloud region (e.g., "bru-2", "us-east-1")
37
+ # @param scope [String, nil] Optional OAuth2 scope for API access control
38
+ def initialize(client_id:, client_secret:, cluster_id:, region:, scope: nil)
39
+ raise ArgumentError, "client_id is required" if client_id.nil?
40
+ raise ArgumentError, "client_secret is required" if client_secret.nil?
41
+ raise ArgumentError, "cluster_id is required" if cluster_id.nil?
42
+ raise ArgumentError, "region is required" if region.nil?
43
+
44
+ @cluster_id = cluster_id
45
+ @region = region
46
+
47
+ super(
48
+ token_url: CAMUNDA_AUTH_URL,
49
+ client_id: client_id,
50
+ client_secret: client_secret,
51
+ audience: CAMUNDA_AUDIENCE,
52
+ scope: scope,
53
+ cluster_address: "#{cluster_id}.#{region}.zeebe.camunda.io:443"
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/credentials"
4
+
5
+ module Busybee
6
+ class Credentials
7
+ # Insecure credentials for local development, docker-compose, and CI.
8
+ # No TLS, no authentication.
9
+ #
10
+ # @example Connect to local Zeebe
11
+ # credentials = Busybee::Credentials::Insecure.new
12
+ # stub = credentials.grpc_stub
13
+ #
14
+ # @example Connect to custom address
15
+ # credentials = Busybee::Credentials::Insecure.new(cluster_address: "zeebe:26500")
16
+ # stub = credentials.grpc_stub
17
+ #
18
+ class Insecure < Credentials
19
+ def grpc_channel_credentials
20
+ :this_channel_is_insecure
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/cache"
5
+ require "grpc"
6
+ require "json"
7
+ require "net/http"
8
+
9
+ require "busybee/credentials"
10
+ require "busybee/error"
11
+
12
+ module Busybee
13
+ class Credentials
14
+ # OAuth2 credentials with automatic token refresh.
15
+ # Combines TLS channel credentials with OAuth2 call credentials.
16
+ #
17
+ # Token caching uses ActiveSupport::Cache with race_condition_ttl to prevent
18
+ # thundering herd during refresh - multiple threads won't block waiting for
19
+ # a refresh when the token is still valid.
20
+ #
21
+ # @example Basic usage
22
+ # credentials = Busybee::Credentials::OAuth.new(
23
+ # token_url: "https://auth.example.com/oauth/token",
24
+ # client_id: "my-client-id",
25
+ # client_secret: "my-client-secret",
26
+ # audience: "zeebe-api",
27
+ # cluster_address: "zeebe.example.com:443"
28
+ # )
29
+ # stub = credentials.grpc_stub
30
+ #
31
+ # @example With custom CA certificate
32
+ # credentials = Busybee::Credentials::OAuth.new(
33
+ # token_url: "https://auth.example.com/oauth/token",
34
+ # client_id: "my-client-id",
35
+ # client_secret: "my-client-secret",
36
+ # audience: "zeebe-api",
37
+ # cluster_address: "zeebe.example.com:443",
38
+ # certificate_file: "/path/to/ca-cert.pem"
39
+ # )
40
+ #
41
+ class OAuth < Credentials
42
+ # These constants may become configuration options in a future version.
43
+ DEFAULT_EXPIRY_SECONDS = 60 * 50 # 50 minutes
44
+ RACE_CONDITION_TTL_SECONDS = 30
45
+ TOKEN_CACHE_SIZE_BYTES = 4 * 1024 * 1024 # 4MB
46
+
47
+ # @param token_url [String] OAuth2 token endpoint URL
48
+ # @param client_id [String] OAuth2 client ID
49
+ # @param client_secret [String] OAuth2 client secret
50
+ # @param audience [String] OAuth2 audience (API identifier)
51
+ # @param scope [String, nil] Optional OAuth2 scope for API access control
52
+ # @param cluster_address [String, nil] Zeebe cluster address (host:port)
53
+ # @param certificate_file [String, nil] Optional CA certificate file path
54
+ def initialize( # rubocop:disable Metrics/ParameterLists
55
+ token_url:,
56
+ client_id:,
57
+ client_secret:,
58
+ audience:,
59
+ scope: nil,
60
+ cluster_address: nil,
61
+ certificate_file: nil
62
+ )
63
+ raise ArgumentError, "token_url is required" if token_url.nil?
64
+ raise ArgumentError, "client_id is required" if client_id.nil?
65
+ raise ArgumentError, "client_secret is required" if client_secret.nil?
66
+ raise ArgumentError, "audience is required" if audience.nil?
67
+
68
+ super(cluster_address: cluster_address)
69
+ @token_uri = URI(token_url)
70
+ @client_id = client_id
71
+ @client_secret = client_secret
72
+ @audience = audience
73
+ @scope = scope
74
+ @certificate_file = certificate_file
75
+ @current_expiry = DEFAULT_EXPIRY_SECONDS - RACE_CONDITION_TTL_SECONDS
76
+ end
77
+
78
+ def grpc_channel_credentials
79
+ build_tls_credentials.compose(grpc_call_credentials)
80
+ end
81
+
82
+ private
83
+
84
+ def grpc_call_credentials
85
+ ::GRPC::Core::CallCredentials.new(method(:token_updater).to_proc)
86
+ end
87
+
88
+ def build_tls_credentials
89
+ if @certificate_file
90
+ ::GRPC::Core::ChannelCredentials.new(File.read(@certificate_file))
91
+ else
92
+ ::GRPC::Core::ChannelCredentials.new
93
+ end
94
+ end
95
+
96
+ def current_token
97
+ # Use race_condition_ttl to prevent thundering herd:
98
+ # - When token is fresh, multiple threads read from cache
99
+ # - 30s before expiry, first thread refreshes while others use stale token
100
+ fetch_options = { expires_in: @current_expiry, race_condition_ttl: RACE_CONDITION_TTL_SECONDS }
101
+ token_cache.fetch(cache_key, fetch_options) do |_key, options = nil|
102
+ token_data = fetch_token_response
103
+
104
+ @current_expiry =
105
+ token_data.fetch("expires_in", DEFAULT_EXPIRY_SECONDS).to_i - RACE_CONDITION_TTL_SECONDS
106
+
107
+ # Set cache expiry dynamically if possible (Rails 7.1+ only):
108
+ options.expires_in = @current_expiry if options&.respond_to?(:expires_in=) # rubocop:disable Lint/RedundantSafeNavigation
109
+
110
+ token_data["access_token"]
111
+ end
112
+ end
113
+
114
+ def fetch_token_response
115
+ response = http_client.request(build_token_request)
116
+ unless response.is_a?(Net::HTTPSuccess)
117
+ raise Busybee::OAuthTokenRefreshFailed, "HTTP #{response.code}: #{response.body}"
118
+ end
119
+
120
+ JSON.parse(response.body)
121
+ rescue JSON::ParserError => e
122
+ raise Busybee::InvalidOAuthResponse, "Invalid JSON response from token endpoint: #{e.message}"
123
+ end
124
+
125
+ def http_client
126
+ Net::HTTP.new(@token_uri.host, @token_uri.port).tap do |client|
127
+ client.use_ssl = (@token_uri.scheme == "https")
128
+ end
129
+ end
130
+
131
+ def build_token_request
132
+ Net::HTTP::Post.new(@token_uri.path).tap do |request|
133
+ form_data = {
134
+ "grant_type" => "client_credentials",
135
+ "client_id" => @client_id,
136
+ "client_secret" => @client_secret,
137
+ "audience" => @audience
138
+ }
139
+ form_data["scope"] = @scope if @scope
140
+ request.set_form_data(form_data)
141
+ end
142
+ end
143
+
144
+ def token_updater(_context)
145
+ { authorization: "Bearer #{current_token}" }
146
+ end
147
+
148
+ def cache_key
149
+ @cache_key ||= "busybee:oauth_token:#{@audience}:#{@client_id}"
150
+ end
151
+
152
+ def token_cache
153
+ @token_cache ||= ActiveSupport::Cache::MemoryStore.new(size: TOKEN_CACHE_SIZE_BYTES)
154
+ end
155
+ end
156
+ end
157
+ end