nats_pubsub 1.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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. metadata +225 -0
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # FluentBatch provides a modern, chainable API for batch publishing
5
+ #
6
+ # @example Basic usage
7
+ # result = NatsPubsub.batch do |b|
8
+ # b.add 'user.created', { id: 1, name: 'Alice' }
9
+ # b.add 'user.created', { id: 2, name: 'Bob' }
10
+ # b.add 'notification.sent', { user_id: 1 }
11
+ # b.with_options trace_id: 'batch-123'
12
+ # end.publish
13
+ #
14
+ # puts "Published #{result.succeeded}/#{result.total} messages"
15
+ #
16
+ class FluentBatch
17
+ # @!attribute [r] items
18
+ # @return [Array<Hash>] The batch items
19
+ attr_reader :items
20
+
21
+ # @!attribute [r] options
22
+ # @return [Hash] Shared options for all messages
23
+ attr_reader :options
24
+
25
+ # Initialize a new batch publisher
26
+ #
27
+ # @param publisher [Publisher] Optional publisher instance
28
+ def initialize(publisher = nil)
29
+ @publisher = publisher || NatsPubsub::Publisher.new
30
+ @items = []
31
+ @options = {}
32
+ end
33
+
34
+ # Add a message to the batch
35
+ #
36
+ # @param topic [String] Topic to publish to
37
+ # @param message [Hash] Message payload
38
+ # @return [self] For chaining
39
+ #
40
+ # @example
41
+ # batch.add('user.created', { id: 1, name: 'Alice' })
42
+ def add(topic, message)
43
+ @items << { topic: topic, message: message }
44
+ self
45
+ end
46
+
47
+ # Set options for all messages in the batch
48
+ #
49
+ # @param options [Hash] Options to apply to all messages
50
+ # @option options [String] :trace_id Distributed tracing ID
51
+ # @option options [String] :correlation_id Request correlation ID
52
+ # @option options [String] :event_id Event ID
53
+ # @option options [Time] :occurred_at Event timestamp
54
+ # @return [self] For chaining
55
+ #
56
+ # @example
57
+ # batch.with_options(trace_id: 'trace-123', correlation_id: 'req-456')
58
+ def with_options(**options)
59
+ @options.merge!(options)
60
+ self
61
+ end
62
+
63
+ # Publish all messages in the batch
64
+ #
65
+ # Messages are published in parallel for performance.
66
+ #
67
+ # @return [BatchResult] Result with success/failure details
68
+ #
69
+ # @example
70
+ # result = batch.publish
71
+ # puts "Succeeded: #{result.succeeded}, Failed: #{result.failed}"
72
+ def publish
73
+ start_time = Time.now
74
+
75
+ return empty_result if @items.empty?
76
+
77
+ logger&.debug "Publishing batch of #{@items.size} messages"
78
+
79
+ # Publish all items in parallel using threads
80
+ results = publish_parallel
81
+
82
+ succeeded = results.count { |r| r[:success] }
83
+ failed = results.count { |r| !r[:success] }
84
+
85
+ duration = ((Time.now - start_time) * 1000).round(2)
86
+
87
+ logger&.info "Batch publish completed: #{succeeded}/#{@items.size} succeeded, #{failed} failed (#{duration}ms)"
88
+
89
+ BatchResult.new(
90
+ total: @items.size,
91
+ succeeded: succeeded,
92
+ failed: failed,
93
+ results: results,
94
+ duration: duration
95
+ )
96
+ end
97
+
98
+ # Clear all items from the batch
99
+ #
100
+ # @return [self] For chaining
101
+ def clear
102
+ @items = []
103
+ self
104
+ end
105
+
106
+ # Get the number of items in the batch
107
+ #
108
+ # @return [Integer] Number of items
109
+ def size
110
+ @items.size
111
+ end
112
+
113
+ alias count size
114
+ alias length size
115
+
116
+ private
117
+
118
+ # Publish items in parallel using threads
119
+ #
120
+ # @return [Array<Hash>] Array of result hashes
121
+ def publish_parallel
122
+ threads = @items.map.with_index do |item, index|
123
+ Thread.new do
124
+ publish_single_item(item, index)
125
+ rescue StandardError => e
126
+ {
127
+ topic: item[:topic],
128
+ success: false,
129
+ error: e.message,
130
+ index: index
131
+ }
132
+ end
133
+ end
134
+
135
+ threads.map(&:value)
136
+ end
137
+
138
+ # Publish a single item
139
+ #
140
+ # @param item [Hash] Item to publish
141
+ # @param index [Integer] Index in the batch
142
+ # @return [Hash] Result hash
143
+ def publish_single_item(item, index)
144
+ @publisher.publish(
145
+ topic: item[:topic],
146
+ message: item[:message],
147
+ **@options
148
+ )
149
+
150
+ {
151
+ topic: item[:topic],
152
+ success: true,
153
+ event_id: @options[:event_id],
154
+ index: index
155
+ }
156
+ rescue StandardError => e
157
+ logger&.error "Batch publish failed for topic #{item[:topic]}: #{e.message}"
158
+
159
+ {
160
+ topic: item[:topic],
161
+ success: false,
162
+ error: e.message,
163
+ index: index
164
+ }
165
+ end
166
+
167
+ # Get logger from config
168
+ #
169
+ # @return [Logger, nil] Logger instance
170
+ def logger
171
+ NatsPubsub.config.logger
172
+ end
173
+
174
+ # Create empty result
175
+ #
176
+ # @return [BatchResult] Empty result
177
+ def empty_result
178
+ BatchResult.new(
179
+ total: 0,
180
+ succeeded: 0,
181
+ failed: 0,
182
+ results: [],
183
+ duration: 0.0
184
+ )
185
+ end
186
+ end
187
+
188
+ # Result of a batch publish operation
189
+ #
190
+ # @!attribute [r] total
191
+ # @return [Integer] Total number of messages
192
+ # @!attribute [r] succeeded
193
+ # @return [Integer] Number of successful messages
194
+ # @!attribute [r] failed
195
+ # @return [Integer] Number of failed messages
196
+ # @!attribute [r] results
197
+ # @return [Array<Hash>] Detailed results for each message
198
+ # @!attribute [r] duration
199
+ # @return [Float] Duration in milliseconds
200
+ #
201
+ class BatchResult
202
+ attr_reader :total, :succeeded, :failed, :results, :duration
203
+
204
+ # Initialize a new batch result
205
+ #
206
+ # @param total [Integer] Total number of messages
207
+ # @param succeeded [Integer] Number of successful messages
208
+ # @param failed [Integer] Number of failed messages
209
+ # @param results [Array<Hash>] Detailed results
210
+ # @param duration [Float] Duration in milliseconds
211
+ def initialize(total:, succeeded:, failed:, results:, duration:)
212
+ @total = total
213
+ @succeeded = succeeded
214
+ @failed = failed
215
+ @results = results
216
+ @duration = duration
217
+ end
218
+
219
+ # Check if all messages succeeded
220
+ #
221
+ # @return [Boolean] True if all succeeded
222
+ def success?
223
+ failed.zero?
224
+ end
225
+
226
+ # Check if any messages failed
227
+ #
228
+ # @return [Boolean] True if any failed
229
+ def failure?
230
+ failed.positive?
231
+ end
232
+
233
+ # Get failed results
234
+ #
235
+ # @return [Array<Hash>] Failed results
236
+ def failures
237
+ results.reject { |r| r[:success] }
238
+ end
239
+
240
+ # Get successful results
241
+ #
242
+ # @return [Array<Hash>] Successful results
243
+ def successes
244
+ results.select { |r| r[:success] }
245
+ end
246
+
247
+ # Convert to hash
248
+ #
249
+ # @return [Hash] Hash representation
250
+ def to_h
251
+ {
252
+ total: total,
253
+ succeeded: succeeded,
254
+ failed: failed,
255
+ results: results,
256
+ duration: duration
257
+ }
258
+ end
259
+
260
+ alias to_hash to_h
261
+ end
262
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+ require_relative '../models/model_utils'
5
+ require_relative 'outbox_repository'
6
+ require_relative 'publish_result'
7
+
8
+ module NatsPubsub
9
+ # Service object responsible for publishing messages through the Outbox pattern
10
+ # Extracts outbox publishing logic from Publisher following Single Responsibility Principle
11
+ #
12
+ # The Outbox pattern ensures reliable message delivery by:
13
+ # 1. Persisting the message to the database before publishing
14
+ # 2. Publishing to NATS
15
+ # 3. Marking as sent in the database
16
+ #
17
+ # This prevents message loss if the application crashes between publishing and commit
18
+ class OutboxPublisher
19
+ # Publish a message using the Outbox pattern
20
+ #
21
+ # @param subject [String] NATS subject
22
+ # @param envelope [Hash] Message envelope
23
+ # @param event_id [String] Event ID
24
+ # @param publisher_block [Proc] Block that performs the actual publish
25
+ # @return [PublishResult] Result object
26
+ def self.publish(subject:, envelope:, event_id:, &publisher_block)
27
+ new(subject: subject, envelope: envelope, event_id: event_id, publisher_block: publisher_block).publish
28
+ end
29
+
30
+ def initialize(subject:, envelope:, event_id:, publisher_block:)
31
+ @subject = subject
32
+ @envelope = envelope
33
+ @event_id = event_id
34
+ @publisher_block = publisher_block
35
+ end
36
+
37
+ def publish
38
+ # Validate outbox model configuration
39
+ klass = ModelUtils.constantize(NatsPubsub.config.outbox_model)
40
+
41
+ unless ModelUtils.ar_class?(klass)
42
+ Logging.warn(
43
+ "Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
44
+ tag: 'NatsPubsub::OutboxPublisher'
45
+ )
46
+ return @publisher_block.call
47
+ end
48
+
49
+ # Use repository pattern for database operations
50
+ repo = OutboxRepository.new(klass)
51
+ record = repo.find_or_build(@event_id)
52
+
53
+ # Skip if already sent (idempotency)
54
+ if repo.already_sent?(record)
55
+ Logging.info(
56
+ "Outbox already sent event_id=#{@event_id}; skipping publish.",
57
+ tag: 'NatsPubsub::OutboxPublisher'
58
+ )
59
+ return PublishResult.success(event_id: @event_id, subject: @subject)
60
+ end
61
+
62
+ # Persist pre-publish state
63
+ repo.persist_pre(record, @subject, @envelope)
64
+
65
+ # Attempt to publish
66
+ result = @publisher_block.call
67
+
68
+ # Update record based on result
69
+ if result.success?
70
+ repo.persist_success(record)
71
+ else
72
+ repo.persist_failure(record, result.details || 'Publish failed')
73
+ end
74
+
75
+ result
76
+ rescue StandardError => e
77
+ # Persist exception if repository and record are available
78
+ repo.persist_exception(record, e) if defined?(repo) && defined?(record)
79
+
80
+ # Return failure result
81
+ Logging.error(
82
+ "Outbox publish failed: #{e.class} #{e.message}",
83
+ tag: 'NatsPubsub::OutboxPublisher'
84
+ )
85
+ PublishResult.failure(
86
+ reason: :exception,
87
+ details: "#{e.class}: #{e.message}",
88
+ subject: @subject,
89
+ error: e
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :subject, :envelope, :event_id, :publisher_block
96
+ end
97
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/base_repository'
4
+ require_relative '../models/model_utils'
5
+ require_relative '../core/logging'
6
+
7
+ module NatsPubsub
8
+ # Encapsulates AR-backed outbox persistence operations.
9
+ # Inherits common patterns from BaseRepository.
10
+ class OutboxRepository < BaseRepository
11
+ def already_sent?(record)
12
+ has_attribute?(record, :sent_at) && record.sent_at
13
+ end
14
+
15
+ def persist_pre(record, subject, envelope)
16
+ now = Time.now.utc
17
+ event_id = envelope['event_id'].to_s
18
+
19
+ attrs = build_pre_publish_attrs(record, event_id, subject, envelope, now)
20
+ assign_attributes(record, attrs)
21
+ save_record!(record)
22
+ end
23
+
24
+ def persist_success(record)
25
+ attrs = { status: 'sent' }
26
+ attrs[:sent_at] = Time.now.utc if has_attribute?(record, :sent_at)
27
+ update_with_timestamp(record, attrs)
28
+ end
29
+
30
+ def persist_failure(record, error_msg)
31
+ attrs = { status: 'failed', last_error: error_msg }
32
+ update_with_timestamp(record, attrs)
33
+ end
34
+
35
+ def persist_exception(record, error)
36
+ return unless record
37
+
38
+ persist_failure(record, "#{error.class}: #{error.message}")
39
+ rescue StandardError => e
40
+ Logging.warn(
41
+ "Failed to persist outbox failure: #{e.class}: #{e.message}",
42
+ tag: 'NatsPubsub::Publisher'
43
+ )
44
+ end
45
+
46
+ # Batch operations for improved performance
47
+ def mark_batch_as_sent(records)
48
+ return 0 if records.empty?
49
+
50
+ ids = records.map { |r| model_utils.pk_value(r) }.compact
51
+ return 0 if ids.empty?
52
+
53
+ model = model_for(records.first)
54
+ now = Time.now.utc
55
+
56
+ attrs = { status: 'sent', updated_at: now }
57
+ attrs[:sent_at] = now if model.has_column?(:sent_at)
58
+
59
+ model.where(model.primary_key => ids).update_all(attrs)
60
+ end
61
+
62
+ def mark_batch_as_failed(records, error_msg)
63
+ return 0 if records.empty?
64
+
65
+ ids = records.map { |r| model_utils.pk_value(r) }.compact
66
+ return 0 if ids.empty?
67
+
68
+ model = model_for(records.first)
69
+
70
+ attrs = {
71
+ status: 'failed',
72
+ last_error: error_msg.to_s.truncate(1000),
73
+ updated_at: Time.now.utc
74
+ }
75
+
76
+ model.where(model.primary_key => ids).update_all(attrs)
77
+ end
78
+
79
+ def cleanup_sent_events(retention_period = 7.days.ago)
80
+ model = outbox_model
81
+ return 0 unless model
82
+
83
+ scope = model.sent
84
+ scope = scope.where('sent_at < ?', retention_period) if model.has_column?(:sent_at)
85
+ scope.delete_all
86
+ end
87
+
88
+ def reset_stale_publishing(threshold = 5.minutes.ago)
89
+ model = outbox_model
90
+ return 0 unless model
91
+
92
+ attrs = {
93
+ status: 'pending',
94
+ last_error: 'Reset from stale publishing state',
95
+ updated_at: Time.now.utc
96
+ }
97
+
98
+ model.stale_publishing(threshold).update_all(attrs)
99
+ end
100
+
101
+ private
102
+
103
+ def build_pre_publish_attrs(record, event_id, subject, envelope, timestamp)
104
+ attrs = {
105
+ event_id: event_id,
106
+ subject: subject,
107
+ payload: ModelUtils.json_dump(envelope),
108
+ headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
109
+ status: 'publishing',
110
+ last_error: nil
111
+ }
112
+ attrs[:attempts] = 1 + (record.attempts || 0) if has_attribute?(record, :attempts)
113
+ attrs[:enqueued_at] = (record.enqueued_at || timestamp) if has_attribute?(record, :enqueued_at)
114
+ attrs
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # Parses and validates arguments for the unified Publisher#publish interface
5
+ # Extracts complex argument parsing logic following Single Responsibility Principle
6
+ #
7
+ # Supports three publishing patterns:
8
+ # 1. Topic-based: publish(topic, message) or publish(topic:, message:)
9
+ # 2. Domain/resource/action: publish(domain:, resource:, action:, payload:)
10
+ # 3. Multi-topic: publish(topics:, message:)
11
+ class PublishArgumentParser
12
+ # Parse result containing method to call and arguments
13
+ ParseResult = Struct.new(:method, :args, :kwargs) do
14
+ def call(publisher)
15
+ publisher.public_send(method, *args, **kwargs)
16
+ end
17
+ end
18
+
19
+ # Parse arguments and determine which publishing method to use
20
+ #
21
+ # @param args [Array] Positional arguments
22
+ # @param kwargs [Hash] Keyword arguments
23
+ # @return [ParseResult] Parse result with method and arguments
24
+ # @raise [ArgumentError] if arguments are invalid
25
+ def self.parse(*args, **kwargs)
26
+ # Multi-topic publishing
27
+ if multi_topic?(kwargs)
28
+ return ParseResult.new(
29
+ :publish_to_topics,
30
+ [kwargs[:topics], kwargs[:message]],
31
+ kwargs.except(:topics, :message)
32
+ )
33
+ end
34
+
35
+ # Domain/resource/action pattern
36
+ if domain_resource_action?(kwargs)
37
+ return ParseResult.new(
38
+ :publish_event,
39
+ [kwargs[:domain], kwargs[:resource], kwargs[:action], kwargs[:payload]],
40
+ kwargs.except(:domain, :resource, :action, :payload)
41
+ )
42
+ end
43
+
44
+ # Topic-based pattern (positional args)
45
+ if positional_topic?(args)
46
+ return ParseResult.new(
47
+ :publish_to_topic,
48
+ [args[0], args[1]],
49
+ kwargs
50
+ )
51
+ end
52
+
53
+ # Topic-based pattern (keyword args)
54
+ if keyword_topic?(kwargs)
55
+ return ParseResult.new(
56
+ :publish_to_topic,
57
+ [kwargs[:topic], kwargs[:message]],
58
+ kwargs.except(:topic, :message)
59
+ )
60
+ end
61
+
62
+ # Invalid arguments
63
+ raise ArgumentError,
64
+ 'Invalid arguments. Use publish(topic, message, **opts) or ' \
65
+ 'publish(domain:, resource:, action:, payload:, **opts) or ' \
66
+ 'publish(topics:, message:, **opts)'
67
+ end
68
+
69
+ # Check if arguments match multi-topic pattern
70
+ #
71
+ # @param kwargs [Hash] Keyword arguments
72
+ # @return [Boolean] True if multi-topic pattern
73
+ def self.multi_topic?(kwargs)
74
+ kwargs.key?(:topics) && kwargs.key?(:message)
75
+ end
76
+ private_class_method :multi_topic?
77
+
78
+ # Check if arguments match domain/resource/action pattern
79
+ #
80
+ # @param kwargs [Hash] Keyword arguments
81
+ # @return [Boolean] True if domain/resource/action pattern
82
+ def self.domain_resource_action?(kwargs)
83
+ kwargs.key?(:domain) &&
84
+ kwargs.key?(:resource) &&
85
+ kwargs.key?(:action) &&
86
+ kwargs.key?(:payload)
87
+ end
88
+ private_class_method :domain_resource_action?
89
+
90
+ # Check if arguments match positional topic pattern
91
+ #
92
+ # @param args [Array] Positional arguments
93
+ # @return [Boolean] True if positional topic pattern
94
+ def self.positional_topic?(args)
95
+ args.length >= 2
96
+ end
97
+ private_class_method :positional_topic?
98
+
99
+ # Check if arguments match keyword topic pattern
100
+ #
101
+ # @param kwargs [Hash] Keyword arguments
102
+ # @return [Boolean] True if keyword topic pattern
103
+ def self.keyword_topic?(kwargs)
104
+ kwargs.key?(:topic) && kwargs.key?(:message)
105
+ end
106
+ private_class_method :keyword_topic?
107
+ end
108
+ end