jetstream_bridge 3.0.2 → 4.0.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.
@@ -7,33 +7,185 @@ require_relative '../core/logging'
7
7
  require_relative '../core/config'
8
8
  require_relative '../core/model_utils'
9
9
  require_relative '../core/retry_strategy'
10
+ require_relative '../models/publish_result'
10
11
  require_relative 'outbox_repository'
11
12
 
12
13
  module JetstreamBridge
13
- # Publishes to "{env}.{app}.sync.{dest}".
14
+ # Publishes events to NATS JetStream with reliability features.
15
+ #
16
+ # Publishes events to "{env}.{app}.sync.{dest}" subject pattern.
17
+ # Supports optional transactional outbox pattern for guaranteed delivery.
18
+ #
19
+ # @example Basic publishing
20
+ # publisher = JetstreamBridge::Publisher.new
21
+ # result = publisher.publish(
22
+ # resource_type: "user",
23
+ # event_type: "created",
24
+ # payload: { id: 1, email: "ada@example.com" }
25
+ # )
26
+ # puts "Published: #{result.event_id}" if result.success?
27
+ #
28
+ # @example Publishing with custom retry strategy
29
+ # custom_strategy = MyRetryStrategy.new(max_attempts: 5)
30
+ # publisher = JetstreamBridge::Publisher.new(retry_strategy: custom_strategy)
31
+ # result = publisher.publish(event_type: "user.created", payload: { id: 1 })
32
+ #
33
+ # @example Using convenience method
34
+ # JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
35
+ #
14
36
  class Publisher
37
+ # Initialize a new Publisher instance.
38
+ #
39
+ # @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
40
+ # Defaults to PublisherRetryStrategy with exponential backoff.
41
+ # @raise [ConnectionError] If unable to connect to NATS server
15
42
  def initialize(retry_strategy: nil)
16
43
  @jts = Connection.connect!
17
44
  @retry_strategy = retry_strategy || PublisherRetryStrategy.new
18
45
  end
19
46
 
20
- # @return [Boolean]
21
- def publish(resource_type:, event_type:, payload:, **options)
47
+ # Publishes an event to NATS JetStream.
48
+ #
49
+ # Supports multiple usage patterns for flexibility:
50
+ #
51
+ # 1. Structured parameters (recommended):
52
+ # publish(resource_type: 'user', event_type: 'created', payload: { id: 1, name: 'Ada' })
53
+ #
54
+ # 2. Hash/envelope with dot notation (auto-infers resource_type):
55
+ # publish(event_type: 'user.created', payload: {...})
56
+ #
57
+ # 3. Complete envelope (advanced):
58
+ # publish({ event_type: 'created', resource_type: 'user', payload: {...}, event_id: '...' })
59
+ #
60
+ # When use_outbox is enabled, events are persisted to database first for reliability.
61
+ # The event_id is used for deduplication via NATS message ID header.
62
+ #
63
+ # @param event_or_hash [Hash, nil] Complete event envelope (if using pattern 3)
64
+ # @param resource_type [String, nil] Resource type (e.g., 'user', 'order'). Required for pattern 1.
65
+ # @param event_type [String, nil] Event type (e.g., 'created', 'user.created'). Required for all patterns.
66
+ # @param payload [Hash, nil] Event payload data. Required for all patterns.
67
+ # @param subject [String, nil] Optional NATS subject override. Defaults to config.source_subject.
68
+ # @param options [Hash] Additional options:
69
+ # - event_id [String] Custom event ID (auto-generated if not provided)
70
+ # - trace_id [String] Distributed trace ID
71
+ # - occurred_at [Time, String] Event timestamp (defaults to current time)
72
+ #
73
+ # @return [Models::PublishResult] Result object containing:
74
+ # - success [Boolean] Whether publish succeeded
75
+ # - event_id [String] The published event ID
76
+ # - subject [String] NATS subject used
77
+ # - error [Exception, nil] Error if publish failed
78
+ # - duplicate [Boolean] Whether NATS detected as duplicate
79
+ #
80
+ # @raise [ArgumentError] If required parameters are missing or invalid
81
+ #
82
+ # @example Structured parameters
83
+ # result = publisher.publish(
84
+ # resource_type: "user",
85
+ # event_type: "created",
86
+ # payload: { id: 1, email: "ada@example.com" }
87
+ # )
88
+ # puts "Published: #{result.event_id}" if result.success?
89
+ #
90
+ # @example With options
91
+ # result = publisher.publish(
92
+ # event_type: "user.created",
93
+ # payload: { id: 1 },
94
+ # event_id: "custom-id-123",
95
+ # trace_id: request_id,
96
+ # occurred_at: Time.now.utc
97
+ # )
98
+ #
99
+ # @example Error handling
100
+ # result = publisher.publish(event_type: "order.created", payload: { id: 1 })
101
+ # if result.failure?
102
+ # logger.error "Failed to publish: #{result.error.message}"
103
+ # end
104
+ #
105
+ def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
22
106
  ensure_destination!
23
- envelope = build_envelope(resource_type, event_type, payload, options)
24
- subject = JetstreamBridge.config.source_subject
25
107
 
108
+ params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
109
+ payload: payload, subject: subject, options: options }
110
+ envelope, resolved_subject = route_publish_params(params)
111
+
112
+ do_publish(resolved_subject, envelope)
113
+ rescue ArgumentError
114
+ # Re-raise validation errors for invalid parameters
115
+ raise
116
+ rescue StandardError => e
117
+ # Return failure result for publishing errors
118
+ Models::PublishResult.new(
119
+ success: false,
120
+ event_id: envelope&.[]('event_id') || 'unknown',
121
+ subject: resolved_subject || 'unknown',
122
+ error: e
123
+ )
124
+ end
125
+
126
+ # Internal publish method that routes to appropriate publish strategy.
127
+ #
128
+ # Routes to outbox-based publishing if use_outbox is enabled, otherwise
129
+ # publishes directly to NATS with retry logic.
130
+ #
131
+ # @param subject [String] NATS subject to publish to
132
+ # @param envelope [Hash] Complete event envelope
133
+ # @return [Models::PublishResult] Result object
134
+ # @api private
135
+ def do_publish(subject, envelope)
26
136
  if JetstreamBridge.config.use_outbox
27
137
  publish_via_outbox(subject, envelope)
28
138
  else
29
139
  with_retries { publish_to_nats(subject, envelope) }
30
140
  end
31
- rescue StandardError => e
32
- log_error(false, e)
33
141
  end
34
142
 
35
143
  private
36
144
 
145
+ # Routes publish parameters to appropriate envelope builder
146
+ # @return [Array<Hash, String>] tuple of [envelope, subject]
147
+ def route_publish_params(params)
148
+ if structured_params?(params)
149
+ build_from_structured_params(params)
150
+ elsif keyword_or_hash_params?(params)
151
+ build_from_keyword_or_hash(params)
152
+ else
153
+ raise ArgumentError, 'Either provide (resource_type:, event_type:, payload:) or an event hash'
154
+ end
155
+ end
156
+
157
+ def structured_params?(params)
158
+ params[:resource_type] && params[:event_type] && params[:payload]
159
+ end
160
+
161
+ def keyword_or_hash_params?(params)
162
+ params[:event_type] || params[:payload] || params[:event_or_hash].is_a?(Hash)
163
+ end
164
+
165
+ def build_from_structured_params(params)
166
+ envelope = build_envelope(params[:resource_type], params[:event_type], params[:payload], params[:options])
167
+ resolved_subject = params[:subject] || JetstreamBridge.config.source_subject
168
+ [envelope, resolved_subject]
169
+ end
170
+
171
+ def build_from_keyword_or_hash(params)
172
+ envelope = if params[:event_or_hash].is_a?(Hash)
173
+ normalize_envelope(params[:event_or_hash], params[:options])
174
+ else
175
+ build_from_keywords(params[:event_type], params[:payload], params[:options])
176
+ end
177
+
178
+ resolved_subject = params[:subject] || params[:options][:subject] || JetstreamBridge.config.source_subject
179
+ [envelope, resolved_subject]
180
+ end
181
+
182
+ def build_from_keywords(event_type, payload, options)
183
+ raise ArgumentError, 'event_type is required' unless event_type
184
+ raise ArgumentError, 'payload is required' unless payload
185
+
186
+ normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
187
+ end
188
+
37
189
  def ensure_destination!
38
190
  return unless JetstreamBridge.config.destination_app.to_s.empty?
39
191
 
@@ -55,9 +207,21 @@ module JetstreamBridge
55
207
  "Publish ack error: #{ack.error}",
56
208
  tag: 'JetstreamBridge::Publisher'
57
209
  )
210
+ return Models::PublishResult.new(
211
+ success: false,
212
+ event_id: envelope['event_id'],
213
+ subject: subject,
214
+ error: StandardError.new(ack.error.to_s),
215
+ duplicate: duplicate
216
+ )
58
217
  end
59
218
 
60
- !ack.respond_to?(:error) || ack.error.nil?
219
+ Models::PublishResult.new(
220
+ success: true,
221
+ event_id: envelope['event_id'],
222
+ subject: subject,
223
+ duplicate: duplicate
224
+ )
61
225
  end
62
226
 
63
227
  # ---- Outbox path ----
@@ -81,17 +245,31 @@ module JetstreamBridge
81
245
  "Outbox already sent event_id=#{event_id}; skipping publish.",
82
246
  tag: 'JetstreamBridge::Publisher'
83
247
  )
84
- return true
248
+ return Models::PublishResult.new(
249
+ success: true,
250
+ event_id: event_id,
251
+ subject: subject,
252
+ duplicate: true
253
+ )
85
254
  end
86
255
 
87
256
  repo.persist_pre(record, subject, envelope)
88
257
 
89
- ok = with_retries { publish_to_nats(subject, envelope) }
90
- ok ? repo.persist_success(record) : repo.persist_failure(record, 'Publish returned false')
91
- ok
258
+ result = with_retries { publish_to_nats(subject, envelope) }
259
+ if result.success?
260
+ repo.persist_success(record)
261
+ else
262
+ repo.persist_failure(record, result.error&.message || 'Publish failed')
263
+ end
264
+ result
92
265
  rescue StandardError => e
93
266
  repo.persist_exception(record, e) if defined?(repo) && defined?(record)
94
- log_error(false, e)
267
+ Models::PublishResult.new(
268
+ success: false,
269
+ event_id: envelope['event_id'],
270
+ subject: subject,
271
+ error: e
272
+ )
95
273
  end
96
274
  # ---- /Outbox path ----
97
275
 
@@ -99,15 +277,11 @@ module JetstreamBridge
99
277
  def with_retries(&)
100
278
  @retry_strategy.execute(context: 'Publisher', &)
101
279
  rescue RetryStrategy::RetryExhausted => e
102
- log_error(false, e)
103
- end
104
-
105
- def log_error(val, exc)
106
280
  Logging.error(
107
- "Publish failed: #{exc.class} #{exc.message}",
281
+ "Publish failed after retries: #{e.class} #{e.message}",
108
282
  tag: 'JetstreamBridge::Publisher'
109
283
  )
110
- val
284
+ raise
111
285
  end
112
286
 
113
287
  def build_envelope(resource_type, event_type, payload, options = {})
@@ -123,5 +297,50 @@ module JetstreamBridge
123
297
  'payload' => payload
124
298
  }
125
299
  end
300
+
301
+ # Normalize a hash to match envelope structure, allowing partial envelopes
302
+ def normalize_envelope(hash, options = {})
303
+ hash = hash.transform_keys(&:to_s)
304
+ infer_resource_type_if_needed!(hash)
305
+
306
+ {
307
+ 'event_id' => envelope_event_id(hash, options),
308
+ 'schema_version' => hash['schema_version'] || 1,
309
+ 'event_type' => hash['event_type'] || raise(ArgumentError, 'event_type is required'),
310
+ 'producer' => hash['producer'] || JetstreamBridge.config.app_name,
311
+ 'resource_id' => hash['resource_id'] || extract_resource_id(hash['payload']),
312
+ 'occurred_at' => envelope_occurred_at(hash, options),
313
+ 'trace_id' => envelope_trace_id(hash, options),
314
+ 'resource_type' => hash['resource_type'] || 'event',
315
+ 'payload' => hash['payload'] || raise(ArgumentError, 'payload is required')
316
+ }
317
+ end
318
+
319
+ def infer_resource_type_if_needed!(hash)
320
+ return unless hash['event_type'] && hash['payload'] && !hash['resource_type']
321
+
322
+ # Try to infer from dot notation (e.g., 'user.created' -> 'user')
323
+ parts = hash['event_type'].split('.')
324
+ hash['resource_type'] = parts[0] if parts.size > 1
325
+ end
326
+
327
+ def envelope_event_id(hash, options)
328
+ hash['event_id'] || options[:event_id] || SecureRandom.uuid
329
+ end
330
+
331
+ def envelope_occurred_at(hash, options)
332
+ hash['occurred_at'] || (options[:occurred_at] || Time.now.utc).iso8601
333
+ end
334
+
335
+ def envelope_trace_id(hash, options)
336
+ hash['trace_id'] || options[:trace_id] || SecureRandom.hex(8)
337
+ end
338
+
339
+ def extract_resource_id(payload)
340
+ return '' unless payload
341
+
342
+ payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
343
+ (payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
344
+ end
126
345
  end
127
346
  end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module JetstreamBridge
6
+ # Test helpers for easier testing of JetStream Bridge integrations
7
+ #
8
+ # @example RSpec configuration
9
+ # require 'jetstream_bridge/test_helpers'
10
+ #
11
+ # RSpec.configure do |config|
12
+ # config.include JetstreamBridge::TestHelpers
13
+ #
14
+ # config.before(:each, :jetstream) do
15
+ # JetstreamBridge::TestHelpers.enable_test_mode!
16
+ # end
17
+ #
18
+ # config.after(:each, :jetstream) do
19
+ # JetstreamBridge::TestHelpers.reset_test_mode!
20
+ # end
21
+ # end
22
+ #
23
+ # @example Using in tests
24
+ # RSpec.describe UserService, :jetstream do
25
+ # it "publishes user created event" do
26
+ # service.create_user(name: "Ada")
27
+ #
28
+ # expect(JetstreamBridge).to have_published(
29
+ # event_type: "user.created",
30
+ # payload: hash_including(name: "Ada")
31
+ # )
32
+ # end
33
+ # end
34
+ #
35
+ module TestHelpers
36
+ class << self
37
+ # Enable test mode with in-memory event capture
38
+ #
39
+ # @return [void]
40
+ def enable_test_mode!
41
+ @test_mode = true
42
+ @published_events = []
43
+ @consumed_events = []
44
+ end
45
+
46
+ # Reset test mode and clear captured events
47
+ #
48
+ # @return [void]
49
+ def reset_test_mode!
50
+ @test_mode = false
51
+ @published_events = []
52
+ @consumed_events = []
53
+ end
54
+
55
+ # Check if test mode is enabled
56
+ #
57
+ # @return [Boolean]
58
+ def test_mode?
59
+ @test_mode ||= false
60
+ end
61
+
62
+ # Get all published events captured in test mode
63
+ #
64
+ # @return [Array<Hash>] Array of published event hashes
65
+ def published_events
66
+ @published_events ||= []
67
+ end
68
+
69
+ # Get all consumed events captured in test mode
70
+ #
71
+ # @return [Array<Hash>] Array of consumed event hashes
72
+ def consumed_events
73
+ @consumed_events ||= []
74
+ end
75
+
76
+ # Record a published event (called internally)
77
+ #
78
+ # @param event [Hash] Event data
79
+ # @return [void]
80
+ def record_published_event(event)
81
+ @published_events ||= []
82
+ @published_events << event.dup
83
+ end
84
+
85
+ # Record a consumed event (called internally)
86
+ #
87
+ # @param event [Hash] Event data
88
+ # @return [void]
89
+ def record_consumed_event(event)
90
+ @consumed_events ||= []
91
+ @consumed_events << event.dup
92
+ end
93
+ end
94
+
95
+ # Build a test Event object
96
+ #
97
+ # @param event_type [String] Event type (e.g., "user.created")
98
+ # @param payload [Hash] Event payload
99
+ # @param event_id [String, nil] Optional event ID
100
+ # @param trace_id [String, nil] Optional trace ID
101
+ # @param occurred_at [Time, String, nil] Optional timestamp
102
+ # @param metadata [Hash] Optional metadata
103
+ # @return [Models::Event] Event object
104
+ #
105
+ # @example
106
+ # event = build_jetstream_event(
107
+ # event_type: "user.created",
108
+ # payload: { id: 1, email: "user@example.com" }
109
+ # )
110
+ # handler.call(event)
111
+ #
112
+ def build_jetstream_event(event_type:, payload:, event_id: nil, trace_id: nil, occurred_at: nil, **metadata)
113
+ event_hash = {
114
+ 'event_id' => event_id || SecureRandom.uuid,
115
+ 'schema_version' => 1,
116
+ 'event_type' => event_type,
117
+ 'producer' => 'test',
118
+ 'resource_id' => (payload['id'] || payload[:id] || '').to_s,
119
+ 'occurred_at' => (occurred_at || Time.now.utc).iso8601,
120
+ 'trace_id' => trace_id || SecureRandom.hex(8),
121
+ 'resource_type' => event_type.split('.').first || 'event',
122
+ 'payload' => payload
123
+ }
124
+
125
+ Models::Event.new(
126
+ event_hash,
127
+ metadata: {
128
+ subject: metadata[:subject] || 'test.subject',
129
+ deliveries: metadata[:deliveries] || 1,
130
+ stream: metadata[:stream] || 'test-stream',
131
+ sequence: metadata[:sequence] || 1,
132
+ consumer: metadata[:consumer] || 'test-consumer',
133
+ timestamp: Time.now
134
+ }
135
+ )
136
+ end
137
+
138
+ # Simulate triggering an event to a consumer
139
+ #
140
+ # @param event [Models::Event, Hash] Event to trigger
141
+ # @param handler [Proc, #call] Handler to call with event
142
+ # @return [void]
143
+ #
144
+ # @example
145
+ # event = build_jetstream_event(event_type: "user.created", payload: { id: 1 })
146
+ # trigger_jetstream_event(event, ->(e) { process_event(e) })
147
+ #
148
+ def trigger_jetstream_event(event, handler = nil)
149
+ handler ||= @handler if defined?(@handler)
150
+ raise ArgumentError, 'handler is required' unless handler
151
+
152
+ TestHelpers.record_consumed_event(event.to_h) if TestHelpers.test_mode?
153
+ handler.call(event)
154
+ end
155
+
156
+ # RSpec matchers module
157
+ #
158
+ # @example Include in RSpec
159
+ # RSpec.configure do |config|
160
+ # config.include JetstreamBridge::TestHelpers
161
+ # config.include JetstreamBridge::TestHelpers::Matchers
162
+ # end
163
+ #
164
+ module Matchers
165
+ # Matcher for checking if an event was published
166
+ #
167
+ # @param event_type [String] Event type to match
168
+ # @param payload [Hash] Optional payload attributes to match
169
+ # @return [HavePublished] Matcher instance
170
+ #
171
+ # @example
172
+ # expect(JetstreamBridge).to have_published(
173
+ # event_type: "user.created",
174
+ # payload: { id: 1 }
175
+ # )
176
+ #
177
+ def have_published(event_type:, payload: {})
178
+ HavePublished.new(event_type, payload)
179
+ end
180
+
181
+ # Matcher implementation for have_published
182
+ class HavePublished
183
+ def initialize(event_type, payload_attributes)
184
+ @event_type = event_type
185
+ @payload_attributes = payload_attributes
186
+ end
187
+
188
+ def matches?(_actual)
189
+ TestHelpers.published_events.any? do |event|
190
+ matches_event_type?(event) && matches_payload?(event)
191
+ end
192
+ end
193
+
194
+ def failure_message
195
+ "expected to have published event_type: #{@event_type.inspect} " \
196
+ "with payload: #{@payload_attributes.inspect}\n" \
197
+ "but found events: #{TestHelpers.published_events.map { |e| e['event_type'] }.inspect}"
198
+ end
199
+
200
+ def failure_message_when_negated
201
+ "expected not to have published event_type: #{@event_type.inspect} " \
202
+ "with payload: #{@payload_attributes.inspect}"
203
+ end
204
+
205
+ private
206
+
207
+ def matches_event_type?(event)
208
+ event['event_type'] == @event_type || event[:event_type] == @event_type
209
+ end
210
+
211
+ def matches_payload?(event)
212
+ payload = event['payload'] || event[:payload] || {}
213
+ @payload_attributes.all? do |key, value|
214
+ payload_value = payload[key.to_s] || payload[key.to_sym]
215
+ if value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher)
216
+ value.matches?(payload)
217
+ else
218
+ payload_value == value
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ # Matcher for checking publish result
225
+ #
226
+ # @example
227
+ # result = JetstreamBridge.publish(...)
228
+ # expect(result).to be_publish_success
229
+ #
230
+ def be_publish_success
231
+ BePublishSuccess.new
232
+ end
233
+
234
+ # Matcher implementation for be_publish_success
235
+ class BePublishSuccess
236
+ def matches?(actual)
237
+ actual.respond_to?(:success?) && actual.success?
238
+ end
239
+
240
+ def failure_message
241
+ 'expected PublishResult to be successful but it failed'
242
+ end
243
+
244
+ def failure_message_when_negated
245
+ 'expected PublishResult to not be successful but it was'
246
+ end
247
+ end
248
+
249
+ # Matcher for checking publish failure
250
+ #
251
+ # @example
252
+ # result = JetstreamBridge.publish(...)
253
+ # expect(result).to be_publish_failure
254
+ #
255
+ def be_publish_failure
256
+ BePublishFailure.new
257
+ end
258
+
259
+ # Matcher implementation for be_publish_failure
260
+ class BePublishFailure
261
+ def matches?(actual)
262
+ actual.respond_to?(:failure?) && actual.failure?
263
+ end
264
+
265
+ def failure_message
266
+ 'expected PublishResult to be a failure but it succeeded'
267
+ end
268
+
269
+ def failure_message_when_negated
270
+ 'expected PublishResult to not be a failure but it was'
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '3.0.2'
7
+ VERSION = '4.0.0'
8
8
  end