smart_message 0.0.3 → 0.0.5

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
@@ -19,16 +20,38 @@ module SmartMessage
19
20
 
20
21
  # Registry for proc-based message handlers
21
22
  @@proc_handlers = {}
23
+
24
+ # Class-level version setting
25
+ class << self
26
+ attr_accessor :_version
27
+
28
+ def version(v = nil)
29
+ if v.nil?
30
+ @_version || 1 # Default to version 1 if not set
31
+ else
32
+ @_version = v
33
+
34
+ # Set up version validation for the header
35
+ # This ensures that the header version matches the expected class version
36
+ @expected_header_version = v
37
+ end
38
+ end
39
+
40
+ def expected_header_version
41
+ @expected_header_version || 1
42
+ end
43
+ end
22
44
 
23
45
  include Hashie::Extensions::Dash::PropertyTranslation
24
46
 
25
47
  include SmartMessage::PropertyDescriptions
48
+ include SmartMessage::PropertyValidations
26
49
 
27
50
  include Hashie::Extensions::Coercion
28
51
  include Hashie::Extensions::DeepMerge
29
52
  include Hashie::Extensions::IgnoreUndeclared
30
53
  include Hashie::Extensions::IndifferentAccess
31
- include Hashie::Extensions::MergeInitializer
54
+ # MergeInitializer interferes with required property validation - removed
32
55
  include Hashie::Extensions::MethodAccess
33
56
 
34
57
  # Common attrubutes for all messages
@@ -47,13 +70,28 @@ module SmartMessage
47
70
  @serializer = nil
48
71
  @logger = nil
49
72
 
73
+ # Create header with version validation specific to this message class
74
+ header = SmartMessage::Header.new(
75
+ uuid: SecureRandom.uuid,
76
+ message_class: self.class.to_s,
77
+ published_at: Time.now,
78
+ publisher_pid: Process.pid,
79
+ version: self.class.version
80
+ )
81
+
82
+ # Set up version validation to match the expected class version
83
+ expected_version = self.class.expected_header_version
84
+ header.singleton_class.class_eval do
85
+ define_method(:validate_version!) do
86
+ unless self.version == expected_version
87
+ raise SmartMessage::Errors::ValidationError,
88
+ "Header version must be #{expected_version}, got: #{self.version}"
89
+ end
90
+ end
91
+ end
92
+
50
93
  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
- )
94
+ _sm_header: header
57
95
  }.merge(props)
58
96
 
59
97
  super(attributes, &block)
@@ -62,6 +100,57 @@ module SmartMessage
62
100
 
63
101
  ###################################################
64
102
  ## Common instance methods
103
+
104
+ # Validate that the header version matches the expected version for this class
105
+ def validate_header_version!
106
+ expected = self.class.expected_header_version
107
+ actual = _sm_header.version
108
+ unless actual == expected
109
+ raise SmartMessage::Errors::ValidationError,
110
+ "#{self.class.name} expects version #{expected}, but header has version #{actual}"
111
+ end
112
+ end
113
+
114
+ # Override PropertyValidations validate! to include header and version validation
115
+ def validate!
116
+ # Validate message properties using PropertyValidations
117
+ super
118
+
119
+ # Validate header properties
120
+ _sm_header.validate!
121
+
122
+ # Validate header version matches expected class version
123
+ validate_header_version!
124
+ end
125
+
126
+ # Override PropertyValidations validation_errors to include header errors
127
+ def validation_errors
128
+ errors = []
129
+
130
+ # Get message property validation errors using PropertyValidations
131
+ errors.concat(super.map { |err|
132
+ err.merge(source: 'message')
133
+ })
134
+
135
+ # Get header validation errors
136
+ errors.concat(_sm_header.validation_errors.map { |err|
137
+ err.merge(source: 'header')
138
+ })
139
+
140
+ # Check version mismatch
141
+ expected = self.class.expected_header_version
142
+ actual = _sm_header.version
143
+ unless actual == expected
144
+ errors << {
145
+ property: :version,
146
+ value: actual,
147
+ message: "Expected version #{expected}, got: #{actual}",
148
+ source: 'version_mismatch'
149
+ }
150
+ end
151
+
152
+ errors
153
+ end
65
154
 
66
155
  # SMELL: How does the transport know how to decode a message before
67
156
  # it knows the message class? We need a wrapper around
@@ -82,6 +171,9 @@ module SmartMessage
82
171
  # NOTE: you publish instances; but, you subscribe/unsubscribe at
83
172
  # the class-level
84
173
  def publish
174
+ # Validate the complete message before publishing (now uses overridden validate!)
175
+ validate!
176
+
85
177
  # TODO: move all of the _sm_ property processes into the wrapper
86
178
  _sm_header.published_at = Time.now
87
179
  _sm_header.publisher_pid = Process.pid
@@ -150,6 +242,11 @@ module SmartMessage
150
242
  self.class.to_s
151
243
  end
152
244
 
245
+ # return this class' description
246
+ def description
247
+ self.class.description
248
+ end
249
+
153
250
 
154
251
  # returns a collection of class Set that consists of
155
252
  # the symbolized values of the property names of the message
@@ -173,7 +270,7 @@ module SmartMessage
173
270
 
174
271
  def description(desc = nil)
175
272
  if desc.nil?
176
- @description
273
+ @description || "#{self.name} is a SmartMessage"
177
274
  else
178
275
  @description = desc.to_s
179
276
  end
@@ -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,46 @@
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"
19
46
  end
20
47
  end
@@ -0,0 +1,217 @@
1
+ # lib/smart_message/logger/default.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require 'logger'
6
+ require 'fileutils'
7
+ require 'stringio'
8
+
9
+ module SmartMessage
10
+ module Logger
11
+ # Default logger implementation for SmartMessage
12
+ #
13
+ # This logger automatically detects and uses the best available logging option:
14
+ # - Rails.logger if running in a Rails application
15
+ # - Standard Ruby Logger writing to log/smart_message.log otherwise
16
+ #
17
+ # Usage:
18
+ # # In your message class
19
+ # config do
20
+ # logger SmartMessage::Logger::Default.new
21
+ # end
22
+ #
23
+ # # Or with custom options
24
+ # config do
25
+ # logger SmartMessage::Logger::Default.new(
26
+ # log_file: 'custom/path.log', # File path
27
+ # level: Logger::DEBUG
28
+ # )
29
+ # end
30
+ #
31
+ # # To log to STDOUT instead of a file
32
+ # config do
33
+ # logger SmartMessage::Logger::Default.new(
34
+ # log_file: STDOUT, # STDOUT or STDERR
35
+ # level: Logger::INFO
36
+ # )
37
+ # end
38
+ class Default < Base
39
+ attr_reader :logger, :log_file, :level
40
+
41
+ def initialize(log_file: nil, level: nil)
42
+ @log_file = log_file || default_log_file
43
+ @level = level || default_log_level
44
+
45
+ @logger = setup_logger
46
+ end
47
+
48
+ # Message lifecycle logging methods
49
+
50
+ def log_message_created(message)
51
+ logger.debug { "[SmartMessage] Created: #{message.class.name} - #{message_summary(message)}" }
52
+ end
53
+
54
+ def log_message_published(message, transport)
55
+ logger.info { "[SmartMessage] Published: #{message.class.name} via #{transport.class.name.split('::').last}" }
56
+ end
57
+
58
+ def log_message_received(message_class, payload)
59
+ logger.info { "[SmartMessage] Received: #{message_class.name} (#{payload.bytesize} bytes)" }
60
+ end
61
+
62
+ def log_message_processed(message_class, result)
63
+ logger.info { "[SmartMessage] Processed: #{message_class.name} - #{truncate(result.to_s, 100)}" }
64
+ end
65
+
66
+ def log_message_subscribe(message_class, handler = nil)
67
+ handler_desc = handler ? " with handler: #{handler}" : ""
68
+ logger.info { "[SmartMessage] Subscribed: #{message_class.name}#{handler_desc}" }
69
+ end
70
+
71
+ def log_message_unsubscribe(message_class)
72
+ logger.info { "[SmartMessage] Unsubscribed: #{message_class.name}" }
73
+ end
74
+
75
+ # Error logging
76
+
77
+ def log_error(context, error)
78
+ logger.error { "[SmartMessage] Error in #{context}: #{error.class.name} - #{error.message}" }
79
+ logger.debug { "[SmartMessage] Backtrace:\n#{error.backtrace.join("\n")}" } if error.backtrace
80
+ end
81
+
82
+ def log_warning(message)
83
+ logger.warn { "[SmartMessage] Warning: #{message}" }
84
+ end
85
+
86
+ # General purpose logging methods matching Ruby's Logger interface
87
+
88
+ def debug(message = nil, &block)
89
+ logger.debug(message, &block)
90
+ end
91
+
92
+ def info(message = nil, &block)
93
+ logger.info(message, &block)
94
+ end
95
+
96
+ def warn(message = nil, &block)
97
+ logger.warn(message, &block)
98
+ end
99
+
100
+ def error(message = nil, &block)
101
+ logger.error(message, &block)
102
+ end
103
+
104
+ def fatal(message = nil, &block)
105
+ logger.fatal(message, &block)
106
+ end
107
+
108
+ private
109
+
110
+ def setup_logger
111
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
112
+ # Use Rails logger if available
113
+ setup_rails_logger
114
+ else
115
+ # Use standard Ruby logger
116
+ setup_ruby_logger
117
+ end
118
+ end
119
+
120
+ def setup_rails_logger
121
+ # Wrap Rails.logger to ensure our messages are properly tagged
122
+ RailsLoggerWrapper.new(Rails.logger, level: @level)
123
+ end
124
+
125
+ def setup_ruby_logger
126
+ # Handle IO objects (STDOUT, STDERR) vs file paths
127
+ if @log_file.is_a?(IO) || @log_file.is_a?(StringIO)
128
+ # For STDOUT/STDERR, don't use rotation
129
+ ruby_logger = ::Logger.new(@log_file)
130
+ else
131
+ # For file paths, ensure directory exists and use rotation
132
+ FileUtils.mkdir_p(File.dirname(@log_file))
133
+
134
+ ruby_logger = ::Logger.new(
135
+ @log_file,
136
+ 10, # Keep 10 old log files
137
+ 10_485_760 # Rotate when file reaches 10MB
138
+ )
139
+ end
140
+
141
+ ruby_logger.level = @level
142
+
143
+ # Set a clean formatter
144
+ ruby_logger.formatter = proc do |severity, datetime, progname, msg|
145
+ timestamp = datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')
146
+ "[#{timestamp}] #{severity.ljust(5)} -- : #{msg}\n"
147
+ end
148
+
149
+ ruby_logger
150
+ end
151
+
152
+ def default_log_file
153
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
154
+ Rails.root.join('log', 'smart_message.log').to_s
155
+ else
156
+ 'log/smart_message.log'
157
+ end
158
+ end
159
+
160
+ def default_log_level
161
+ if defined?(Rails) && Rails.respond_to?(:env)
162
+ case Rails.env
163
+ when 'production'
164
+ ::Logger::INFO
165
+ when 'test'
166
+ ::Logger::ERROR
167
+ else
168
+ ::Logger::DEBUG
169
+ end
170
+ else
171
+ # Default to INFO for non-Rails environments
172
+ ::Logger::INFO
173
+ end
174
+ end
175
+
176
+ def message_summary(message)
177
+ # Create a brief summary of the message for logging
178
+ if message.respond_to?(:to_h)
179
+ data = message.to_h
180
+ # Remove internal header for cleaner logs
181
+ data.delete(:_sm_header)
182
+ data.delete('_sm_header')
183
+ truncate(data.inspect, 200)
184
+ else
185
+ truncate(message.inspect, 200)
186
+ end
187
+ end
188
+
189
+ def truncate(string, max_length)
190
+ return string if string.length <= max_length
191
+ "#{string[0...max_length]}..."
192
+ end
193
+
194
+ # Internal wrapper for Rails.logger to handle tagged logging
195
+ class RailsLoggerWrapper
196
+ def initialize(rails_logger, level: nil)
197
+ @rails_logger = rails_logger
198
+ @rails_logger.level = level if level
199
+ end
200
+
201
+ def method_missing(method, *args, &block)
202
+ if @rails_logger.respond_to?(:tagged)
203
+ @rails_logger.tagged('SmartMessage') do
204
+ @rails_logger.send(method, *args, &block)
205
+ end
206
+ else
207
+ @rails_logger.send(method, *args, &block)
208
+ end
209
+ end
210
+
211
+ def respond_to_missing?(method, include_private = false)
212
+ @rails_logger.respond_to?(method, include_private)
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -3,5 +3,13 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  module SmartMessage::Logger
6
- # TODO: write this
6
+ # Logger module provides logging capabilities for SmartMessage
7
+ # The Default logger automatically uses Rails.logger if available,
8
+ # otherwise falls back to a standard Ruby Logger
7
9
  end # module SmartMessage::Logger
10
+
11
+ # Load the base class first
12
+ require_relative 'logger/base'
13
+
14
+ # Load the default logger implementation
15
+ require_relative 'logger/default'
@@ -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)
@@ -0,0 +1,141 @@
1
+ # lib/smart_message/property_validations.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ module SmartMessage
6
+ module PropertyValidations
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ base.class_eval do
10
+ @property_validators = {}
11
+ end
12
+ end
13
+
14
+ module ClassMethods
15
+ def property(property_name, options = {})
16
+ # Extract our custom options before passing to Hashie::Dash
17
+ # Note: Hashie's 'message' option only works with 'required', so we use 'validation_message'
18
+ validator = options.delete(:validate)
19
+ validation_message = options.delete(:validation_message)
20
+
21
+ # Call original property method first
22
+ super(property_name, options)
23
+
24
+ # Then store validator if provided
25
+ if validator
26
+ @property_validators ||= {}
27
+ @property_validators[property_name.to_sym] = {
28
+ validator: validator,
29
+ message: validation_message || "Validation failed for property '#{property_name}'"
30
+ }
31
+
32
+ # Note: We don't override setter methods since they may conflict with Hashie::Dash
33
+ # Instead, validation happens during validate! calls
34
+ end
35
+ end
36
+
37
+ def property_validator(property_name)
38
+ @property_validators&.[](property_name.to_sym)
39
+ end
40
+
41
+ def property_validators
42
+ @property_validators&.dup || {}
43
+ end
44
+
45
+ def validated_properties
46
+ @property_validators&.keys || []
47
+ end
48
+
49
+ # Validate all properties with validators
50
+ def validate_all(instance)
51
+ validated_properties.each do |property_name|
52
+ validator_info = property_validator(property_name)
53
+ next unless validator_info
54
+
55
+ value = instance.send(property_name)
56
+ validator = validator_info[:validator]
57
+ error_message = validator_info[:message]
58
+
59
+ # Skip validation if value is nil and property is not required
60
+ next if value.nil? && !instance.class.required_properties.include?(property_name)
61
+
62
+ # Perform validation
63
+ is_valid = case validator
64
+ when Proc
65
+ instance.instance_exec(value, &validator)
66
+ when Symbol
67
+ instance.send(validator, value)
68
+ when Regexp
69
+ !!(value.to_s =~ validator)
70
+ when Class
71
+ value.is_a?(validator)
72
+ when Array
73
+ validator.include?(value)
74
+ when Range
75
+ validator.include?(value)
76
+ else
77
+ value == validator
78
+ end
79
+
80
+ unless is_valid
81
+ raise SmartMessage::Errors::ValidationError, "#{instance.class.name}##{property_name}: #{error_message}"
82
+ end
83
+ end
84
+ true
85
+ end
86
+ end
87
+
88
+ # Instance methods
89
+ def validate!
90
+ self.class.validate_all(self)
91
+ end
92
+
93
+ def valid?
94
+ validate!
95
+ true
96
+ rescue SmartMessage::Errors::ValidationError
97
+ false
98
+ end
99
+
100
+ def validation_errors
101
+ errors = []
102
+ self.class.validated_properties.each do |property_name|
103
+ validator_info = self.class.property_validator(property_name)
104
+ next unless validator_info
105
+
106
+ value = send(property_name)
107
+ validator = validator_info[:validator]
108
+
109
+ # Skip validation if value is nil and property is not required
110
+ next if value.nil? && !self.class.required_properties.include?(property_name)
111
+
112
+ # Perform validation
113
+ is_valid = case validator
114
+ when Proc
115
+ instance_exec(value, &validator)
116
+ when Symbol
117
+ send(validator, value)
118
+ when Regexp
119
+ !!(value.to_s =~ validator)
120
+ when Class
121
+ value.is_a?(validator)
122
+ when Array
123
+ validator.include?(value)
124
+ when Range
125
+ validator.include?(value)
126
+ else
127
+ value == validator
128
+ end
129
+
130
+ unless is_valid
131
+ errors << {
132
+ property: property_name,
133
+ value: value,
134
+ message: validator_info[:message]
135
+ }
136
+ end
137
+ end
138
+ errors
139
+ end
140
+ end
141
+ end
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  module SmartMessage
6
- VERSION = "0.0.3"
6
+ VERSION = '0.0.5'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_message
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -204,17 +204,21 @@ files:
204
204
  - docs/examples.md
205
205
  - docs/getting-started.md
206
206
  - docs/ideas_to_think_about.md
207
+ - docs/logging.md
207
208
  - docs/message_processing.md
208
209
  - docs/proc_handlers_summary.md
209
210
  - docs/properties.md
210
211
  - docs/serializers.md
211
212
  - docs/transports.md
212
213
  - docs/troubleshooting.md
214
+ - examples/.gitignore
213
215
  - examples/01_point_to_point_orders.rb
214
216
  - examples/02_publish_subscribe_events.rb
215
217
  - examples/03_many_to_many_chat.rb
216
218
  - examples/04_redis_smart_home_iot.rb
217
219
  - examples/05_proc_handlers.rb
220
+ - examples/06_custom_logger_example.rb
221
+ - examples/07_error_handling_scenarios.rb
218
222
  - examples/README.md
219
223
  - examples/smart_home_iot_dataflow.md
220
224
  - examples/tmux_chat/README.md
@@ -233,7 +237,9 @@ files:
233
237
  - lib/smart_message/header.rb
234
238
  - lib/smart_message/logger.rb
235
239
  - lib/smart_message/logger/base.rb
240
+ - lib/smart_message/logger/default.rb
236
241
  - lib/smart_message/property_descriptions.rb
242
+ - lib/smart_message/property_validations.rb
237
243
  - lib/smart_message/serializer.rb
238
244
  - lib/smart_message/serializer/base.rb
239
245
  - lib/smart_message/serializer/json.rb