smart_message 0.0.4 → 0.0.6

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.
@@ -6,6 +6,7 @@ require 'securerandom' # STDLIB
6
6
 
7
7
  require_relative './wrapper.rb'
8
8
  require_relative './property_descriptions.rb'
9
+ require_relative './property_validations.rb'
9
10
 
10
11
  module SmartMessage
11
12
  # The foundation class for the smart message
@@ -17,18 +18,43 @@ module SmartMessage
17
18
  @@serializer = nil
18
19
  @@logger = nil
19
20
 
21
+ # Class-level addressing configuration - use a registry for per-class isolation
22
+ @@addressing_registry = {}
23
+
20
24
  # Registry for proc-based message handlers
21
25
  @@proc_handlers = {}
26
+
27
+ # Class-level version setting
28
+ class << self
29
+ attr_accessor :_version
30
+
31
+ def version(v = nil)
32
+ if v.nil?
33
+ @_version || 1 # Default to version 1 if not set
34
+ else
35
+ @_version = v
36
+
37
+ # Set up version validation for the header
38
+ # This ensures that the header version matches the expected class version
39
+ @expected_header_version = v
40
+ end
41
+ end
42
+
43
+ def expected_header_version
44
+ @expected_header_version || 1
45
+ end
46
+ end
22
47
 
23
48
  include Hashie::Extensions::Dash::PropertyTranslation
24
49
 
25
50
  include SmartMessage::PropertyDescriptions
51
+ include SmartMessage::PropertyValidations
26
52
 
27
53
  include Hashie::Extensions::Coercion
28
54
  include Hashie::Extensions::DeepMerge
29
55
  include Hashie::Extensions::IgnoreUndeclared
30
56
  include Hashie::Extensions::IndifferentAccess
31
- include Hashie::Extensions::MergeInitializer
57
+ # MergeInitializer interferes with required property validation - removed
32
58
  include Hashie::Extensions::MethodAccess
33
59
 
34
60
  # Common attrubutes for all messages
@@ -46,14 +72,37 @@ module SmartMessage
46
72
  @transport = nil
47
73
  @serializer = nil
48
74
  @logger = nil
49
-
75
+
76
+ # instance-level over ride of class addressing
77
+ @from = nil
78
+ @to = nil
79
+ @reply_to = nil
80
+
81
+ # Create header with version validation specific to this message class
82
+ header = SmartMessage::Header.new(
83
+ uuid: SecureRandom.uuid,
84
+ message_class: self.class.to_s,
85
+ published_at: Time.now,
86
+ publisher_pid: Process.pid,
87
+ version: self.class.version,
88
+ from: from,
89
+ to: to,
90
+ reply_to: reply_to
91
+ )
92
+
93
+ # Set up version validation to match the expected class version
94
+ expected_version = self.class.expected_header_version
95
+ header.singleton_class.class_eval do
96
+ define_method(:validate_version!) do
97
+ unless self.version == expected_version
98
+ raise SmartMessage::Errors::ValidationError,
99
+ "Header version must be #{expected_version}, got: #{self.version}"
100
+ end
101
+ end
102
+ end
103
+
50
104
  attributes = {
51
- _sm_header: SmartMessage::Header.new(
52
- uuid: SecureRandom.uuid,
53
- message_class: self.class.to_s,
54
- published_at: 2,
55
- publisher_pid: 3
56
- )
105
+ _sm_header: header
57
106
  }.merge(props)
58
107
 
59
108
  super(attributes, &block)
@@ -62,6 +111,57 @@ module SmartMessage
62
111
 
63
112
  ###################################################
64
113
  ## Common instance methods
114
+
115
+ # Validate that the header version matches the expected version for this class
116
+ def validate_header_version!
117
+ expected = self.class.expected_header_version
118
+ actual = _sm_header.version
119
+ unless actual == expected
120
+ raise SmartMessage::Errors::ValidationError,
121
+ "#{self.class.name} expects version #{expected}, but header has version #{actual}"
122
+ end
123
+ end
124
+
125
+ # Override PropertyValidations validate! to include header and version validation
126
+ def validate!
127
+ # Validate message properties using PropertyValidations
128
+ super
129
+
130
+ # Validate header properties
131
+ _sm_header.validate!
132
+
133
+ # Validate header version matches expected class version
134
+ validate_header_version!
135
+ end
136
+
137
+ # Override PropertyValidations validation_errors to include header errors
138
+ def validation_errors
139
+ errors = []
140
+
141
+ # Get message property validation errors using PropertyValidations
142
+ errors.concat(super.map { |err|
143
+ err.merge(source: 'message')
144
+ })
145
+
146
+ # Get header validation errors
147
+ errors.concat(_sm_header.validation_errors.map { |err|
148
+ err.merge(source: 'header')
149
+ })
150
+
151
+ # Check version mismatch
152
+ expected = self.class.expected_header_version
153
+ actual = _sm_header.version
154
+ unless actual == expected
155
+ errors << {
156
+ property: :version,
157
+ value: actual,
158
+ message: "Expected version #{expected}, got: #{actual}",
159
+ source: 'version_mismatch'
160
+ }
161
+ end
162
+
163
+ errors
164
+ end
65
165
 
66
166
  # SMELL: How does the transport know how to decode a message before
67
167
  # it knows the message class? We need a wrapper around
@@ -82,6 +182,9 @@ module SmartMessage
82
182
  # NOTE: you publish instances; but, you subscribe/unsubscribe at
83
183
  # the class-level
84
184
  def publish
185
+ # Validate the complete message before publishing (now uses overridden validate!)
186
+ validate!
187
+
85
188
  # TODO: move all of the _sm_ property processes into the wrapper
86
189
  _sm_header.published_at = Time.now
87
190
  _sm_header.publisher_pid = Process.pid
@@ -142,6 +245,61 @@ module SmartMessage
142
245
  def reset_serializer; @serializer = nil; end
143
246
 
144
247
 
248
+ #########################################################
249
+ ## instance-level addressing configuration
250
+
251
+ def from(entity_id = nil)
252
+ if entity_id.nil?
253
+ @from || self.class.from
254
+ else
255
+ @from = entity_id
256
+ # Update the header with the new value
257
+ _sm_header.from = entity_id if _sm_header
258
+ end
259
+ end
260
+
261
+ def from_configured?; !from.nil?; end
262
+ def from_missing?; from.nil?; end
263
+ def reset_from;
264
+ @from = nil
265
+ _sm_header.from = nil if _sm_header
266
+ end
267
+
268
+ def to(entity_id = nil)
269
+ if entity_id.nil?
270
+ @to || self.class.to
271
+ else
272
+ @to = entity_id
273
+ # Update the header with the new value
274
+ _sm_header.to = entity_id if _sm_header
275
+ end
276
+ end
277
+
278
+ def to_configured?; !to.nil?; end
279
+ def to_missing?; to.nil?; end
280
+ def reset_to;
281
+ @to = nil
282
+ _sm_header.to = nil if _sm_header
283
+ end
284
+
285
+ def reply_to(entity_id = nil)
286
+ if entity_id.nil?
287
+ @reply_to || self.class.reply_to
288
+ else
289
+ @reply_to = entity_id
290
+ # Update the header with the new value
291
+ _sm_header.reply_to = entity_id if _sm_header
292
+ end
293
+ end
294
+
295
+ def reply_to_configured?; !reply_to.nil?; end
296
+ def reply_to_missing?; reply_to.nil?; end
297
+ def reset_reply_to;
298
+ @reply_to = nil
299
+ _sm_header.reply_to = nil if _sm_header
300
+ end
301
+
302
+
145
303
  #########################################################
146
304
  ## instance-level utility methods
147
305
 
@@ -150,6 +308,11 @@ module SmartMessage
150
308
  self.class.to_s
151
309
  end
152
310
 
311
+ # return this class' description
312
+ def description
313
+ self.class.description
314
+ end
315
+
153
316
 
154
317
  # returns a collection of class Set that consists of
155
318
  # the symbolized values of the property names of the message
@@ -173,7 +336,7 @@ module SmartMessage
173
336
 
174
337
  def description(desc = nil)
175
338
  if desc.nil?
176
- @description
339
+ @description || "#{self.name} is a SmartMessage"
177
340
  else
178
341
  @description = desc.to_s
179
342
  end
@@ -261,6 +424,113 @@ module SmartMessage
261
424
  def reset_serializer; @@serializer = nil; end
262
425
 
263
426
 
427
+ #########################################################
428
+ ## class-level addressing configuration
429
+
430
+ # Helper method to normalize filter values (string -> array, nil -> nil)
431
+ private def normalize_filter_value(value)
432
+ case value
433
+ when nil
434
+ nil
435
+ when String
436
+ [value]
437
+ when Array
438
+ value
439
+ else
440
+ raise ArgumentError, "Filter value must be a String, Array, or nil, got: #{value.class}"
441
+ end
442
+ end
443
+
444
+ # Helper method to find addressing values in the inheritance chain
445
+ private def find_addressing_value(field)
446
+ # Start with current class
447
+ current_class = self
448
+
449
+ while current_class && current_class.respond_to?(:name)
450
+ class_name = current_class.name || current_class.to_s
451
+
452
+ # Check registry for this class
453
+ result = @@addressing_registry.dig(class_name, field)
454
+ return result if result
455
+
456
+ # If we have a proper name but no result, also check the to_s version
457
+ if current_class.name
458
+ alternative_key = current_class.to_s
459
+ result = @@addressing_registry.dig(alternative_key, field)
460
+ return result if result
461
+ end
462
+
463
+ # Move up the inheritance chain
464
+ current_class = current_class.superclass
465
+
466
+ # Stop if we reach SmartMessage::Base or above
467
+ break if current_class == SmartMessage::Base || current_class.nil?
468
+ end
469
+
470
+ nil
471
+ end
472
+
473
+ def from(entity_id = nil)
474
+ class_name = self.name || self.to_s
475
+ if entity_id.nil?
476
+ # Try to find the value, checking inheritance chain
477
+ result = find_addressing_value(:from)
478
+ result
479
+ else
480
+ @@addressing_registry[class_name] ||= {}
481
+ @@addressing_registry[class_name][:from] = entity_id
482
+ end
483
+ end
484
+
485
+ def from_configured?; !from.nil?; end
486
+ def from_missing?; from.nil?; end
487
+ def reset_from;
488
+ class_name = self.name || self.to_s
489
+ @@addressing_registry[class_name] ||= {}
490
+ @@addressing_registry[class_name][:from] = nil
491
+ end
492
+
493
+ def to(entity_id = nil)
494
+ class_name = self.name || self.to_s
495
+ if entity_id.nil?
496
+ # Try to find the value, checking inheritance chain
497
+ result = find_addressing_value(:to)
498
+ result
499
+ else
500
+ @@addressing_registry[class_name] ||= {}
501
+ @@addressing_registry[class_name][:to] = entity_id
502
+ end
503
+ end
504
+
505
+ def to_configured?; !to.nil?; end
506
+ def to_missing?; to.nil?; end
507
+ def reset_to;
508
+ class_name = self.name || self.to_s
509
+ @@addressing_registry[class_name] ||= {}
510
+ @@addressing_registry[class_name][:to] = nil
511
+ end
512
+
513
+ def reply_to(entity_id = nil)
514
+ class_name = self.name || self.to_s
515
+ if entity_id.nil?
516
+ # Try to find the value, checking inheritance chain
517
+ result = find_addressing_value(:reply_to)
518
+ result
519
+ else
520
+ @@addressing_registry[class_name] ||= {}
521
+ @@addressing_registry[class_name][:reply_to] = entity_id
522
+ end
523
+ end
524
+
525
+ def reply_to_configured?; !reply_to.nil?; end
526
+ def reply_to_missing?; reply_to.nil?; end
527
+ def reset_reply_to;
528
+ class_name = self.name || self.to_s
529
+ @@addressing_registry[class_name] ||= {}
530
+ @@addressing_registry[class_name][:reply_to] = nil
531
+ end
532
+
533
+
264
534
  #########################################################
265
535
  ## class-level subscription management via the transport
266
536
 
@@ -272,25 +542,30 @@ module SmartMessage
272
542
  # - String: Method name like "MyService.handle_message"
273
543
  # - Proc: A proc/lambda that accepts (message_header, message_payload)
274
544
  # - nil: Uses default "MessageClass.process" method
545
+ # @param broadcast [Boolean, nil] Filter for broadcast messages (to: nil)
546
+ # @param to [String, Array, nil] Filter for messages directed to specific entities
547
+ # @param from [String, Array, nil] Filter for messages from specific entities
275
548
  # @param block [Proc] Alternative way to pass a processing block
276
549
  # @return [String] The identifier used for this subscription
277
550
  #
278
- # @example Using default handler
551
+ # @example Using default handler (all messages)
279
552
  # MyMessage.subscribe
280
553
  #
281
- # @example Using custom method name
282
- # MyMessage.subscribe("MyService.handle_message")
554
+ # @example Using custom method name with filtering
555
+ # MyMessage.subscribe("MyService.handle_message", to: 'my-service')
283
556
  #
284
- # @example Using a block
285
- # MyMessage.subscribe do |header, payload|
557
+ # @example Using a block with broadcast filtering
558
+ # MyMessage.subscribe(broadcast: true) do |header, payload|
286
559
  # data = JSON.parse(payload)
287
- # puts "Received: #{data}"
560
+ # puts "Received broadcast: #{data}"
288
561
  # end
289
562
  #
290
- # @example Using a proc
291
- # handler = proc { |header, payload| puts "Processing..." }
292
- # MyMessage.subscribe(handler)
293
- def subscribe(process_method = nil, &block)
563
+ # @example Entity-specific filtering
564
+ # MyMessage.subscribe(to: 'order-service', from: ['payment', 'user'])
565
+ #
566
+ # @example Broadcast + directed messages
567
+ # MyMessage.subscribe(to: 'my-service', broadcast: true)
568
+ def subscribe(process_method = nil, broadcast: nil, to: nil, from: nil, &block)
294
569
  message_class = whoami
295
570
 
296
571
  # Handle different parameter types
@@ -308,10 +583,21 @@ module SmartMessage
308
583
  end
309
584
  # If process_method is a String, use it as-is
310
585
 
586
+ # Normalize string filters to arrays
587
+ to_filter = normalize_filter_value(to)
588
+ from_filter = normalize_filter_value(from)
589
+
590
+ # Create filter options
591
+ filter_options = {
592
+ broadcast: broadcast,
593
+ to: to_filter,
594
+ from: from_filter
595
+ }
596
+
311
597
  # TODO: Add proper logging here
312
598
 
313
599
  raise Errors::TransportNotConfigured if transport_missing?
314
- transport.subscribe(message_class, process_method)
600
+ transport.subscribe(message_class, process_method, filter_options)
315
601
 
316
602
  process_method
317
603
  end
@@ -83,17 +83,30 @@ module SmartMessage
83
83
  end
84
84
 
85
85
 
86
- def add(message_class, process_method_as_string)
86
+ def add(message_class, process_method_as_string, filter_options = {})
87
87
  klass = String(message_class)
88
- unless @subscribers[klass].include? process_method_as_string
89
- @subscribers[klass] += [process_method_as_string]
88
+
89
+ # Create subscription entry with filter options
90
+ subscription = {
91
+ process_method: process_method_as_string,
92
+ filters: filter_options
93
+ }
94
+
95
+ # Check if this exact subscription already exists
96
+ existing_subscription = @subscribers[klass].find do |sub|
97
+ sub[:process_method] == process_method_as_string && sub[:filters] == filter_options
98
+ end
99
+
100
+ unless existing_subscription
101
+ @subscribers[klass] += [subscription]
90
102
  end
91
103
  end
92
104
 
93
105
 
94
106
  # drop a processer from a subscribed message
95
107
  def drop(message_class, process_method_as_string)
96
- @subscribers[String(message_class)].delete process_method_as_string
108
+ klass = String(message_class)
109
+ @subscribers[klass].reject! { |sub| sub[:process_method] == process_method_as_string }
97
110
  end
98
111
 
99
112
 
@@ -115,7 +128,15 @@ module SmartMessage
115
128
  def route(message_header, message_payload)
116
129
  message_klass = message_header.message_class
117
130
  return nil if @subscribers[message_klass].empty?
118
- @subscribers[message_klass].each do |message_processor|
131
+
132
+ @subscribers[message_klass].each do |subscription|
133
+ # Extract subscription details
134
+ message_processor = subscription[:process_method]
135
+ filters = subscription[:filters]
136
+
137
+ # Check if message matches filters
138
+ next unless message_matches_filters?(message_header, filters)
139
+
119
140
  SS.add(message_klass, message_processor, 'routed' )
120
141
  @router_pool.post do
121
142
  begin
@@ -144,6 +165,33 @@ module SmartMessage
144
165
 
145
166
  private
146
167
 
168
+ # Check if a message matches the subscription filters
169
+ # @param message_header [SmartMessage::Header] The message header
170
+ # @param filters [Hash] The filter criteria
171
+ # @return [Boolean] True if the message matches all filters
172
+ def message_matches_filters?(message_header, filters)
173
+ # If no filters specified, accept all messages (backward compatibility)
174
+ return true if filters.nil? || filters.empty? || filters.values.all?(&:nil?)
175
+
176
+ # Check from filter
177
+ if filters[:from]
178
+ from_match = filters[:from].include?(message_header.from)
179
+ return false unless from_match
180
+ end
181
+
182
+ # Check to/broadcast filters (OR logic between them)
183
+ if filters[:broadcast] || filters[:to]
184
+ broadcast_match = filters[:broadcast] && message_header.to.nil?
185
+ to_match = filters[:to] && filters[:to].include?(message_header.to)
186
+
187
+ # If either broadcast or to filter is specified, at least one must match
188
+ combined_match = (broadcast_match || to_match)
189
+ return false unless combined_match
190
+ end
191
+
192
+ true
193
+ end
194
+
147
195
  # Check if a message processor is a proc handler
148
196
  # @param message_processor [String] The message processor identifier
149
197
  # @return [Boolean] True if this is a proc handler
@@ -25,5 +25,8 @@ module SmartMessage
25
25
  # A received message is of an unknown class
26
26
  class UnknownMessageClass < RuntimeError; end
27
27
 
28
+ # A property validation failed
29
+ class ValidationError < RuntimeError; end
30
+
28
31
  end
29
32
  end
@@ -2,19 +2,60 @@
2
2
  # encoding: utf-8
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative './property_descriptions'
6
+ require_relative './property_validations'
7
+
5
8
  module SmartMessage
6
9
  # Every smart message has a common header format that contains
7
10
  # information used to support the dispatching of subscribed
8
11
  # messages upon receipt from a transport.
9
12
  class Header < Hashie::Dash
10
13
  include Hashie::Extensions::IndifferentAccess
11
- include Hashie::Extensions::MergeInitializer
12
14
  include Hashie::Extensions::MethodAccess
15
+ include SmartMessage::PropertyDescriptions
16
+ include SmartMessage::PropertyValidations
13
17
 
14
18
  # Common attributes of the smart message standard header
15
- property :uuid
16
- property :message_class
17
- property :published_at
18
- property :publisher_pid
19
+ property :uuid,
20
+ required: true,
21
+ message: "UUID is required for message tracking and deduplication",
22
+ description: "Unique identifier for this specific message instance, used for tracking and deduplication"
23
+
24
+ property :message_class,
25
+ required: true,
26
+ message: "Message class is required to identify the message type",
27
+ description: "Fully qualified class name of the message type (e.g. 'OrderMessage', 'PaymentNotification')"
28
+
29
+ property :published_at,
30
+ required: true,
31
+ message: "Published timestamp is required for message ordering",
32
+ description: "Timestamp when the message was published by the sender, used for ordering and debugging"
33
+
34
+ property :publisher_pid,
35
+ required: true,
36
+ message: "Publisher process ID is required for debugging and traceability",
37
+ description: "Process ID of the publishing application, useful for debugging and tracing message origins"
38
+
39
+ property :version,
40
+ required: true,
41
+ default: 1,
42
+ message: "Message version is required for schema compatibility",
43
+ description: "Schema version of the message format, used for schema evolution and compatibility checking",
44
+ validate: ->(v) { v.is_a?(Integer) && v > 0 },
45
+ validation_message: "Header version must be a positive integer"
46
+
47
+ # Message addressing properties for entity-to-entity communication
48
+ property :from,
49
+ required: true,
50
+ message: "From entity ID is required for message routing and replies",
51
+ description: "Unique identifier of the entity sending this message, used for routing responses and audit trails"
52
+
53
+ property :to,
54
+ required: false,
55
+ description: "Optional unique identifier of the intended recipient entity. When nil, message is broadcast to all subscribers"
56
+
57
+ property :reply_to,
58
+ required: false,
59
+ description: "Optional unique identifier of the entity that should receive replies to this message. Defaults to 'from' entity if not specified"
19
60
  end
20
61
  end
@@ -13,16 +13,17 @@ module SmartMessage
13
13
 
14
14
  module ClassMethods
15
15
  def property(property_name, options = {})
16
+ # Extract our custom option before passing to parent
16
17
  description = options.delete(:description)
17
18
 
18
- # Store description if provided
19
+ # Call original property method first
20
+ super(property_name, options)
21
+
22
+ # Then store description if provided
19
23
  if description
20
24
  @property_descriptions ||= {}
21
25
  @property_descriptions[property_name.to_sym] = description
22
26
  end
23
-
24
- # Call original property method
25
- super(property_name, options)
26
27
  end
27
28
 
28
29
  def property_description(property_name)