nats_wave 1.1.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +8 -0
  3. data/.idea/misc.xml +4 -0
  4. data/.idea/modules.xml +8 -0
  5. data/.idea/nats_wave.iml +169 -0
  6. data/.idea/vcs.xml +6 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +16 -0
  9. data/CHANGELOG.md +5 -0
  10. data/CODE_OF_CONDUCT.md +136 -0
  11. data/Gemfile +15 -0
  12. data/Gemfile.lock +332 -0
  13. data/LICENSE.txt +21 -0
  14. data/README.md +985 -0
  15. data/Rakefile +12 -0
  16. data/config/nats_wave.yml +65 -0
  17. data/examples/catalog_model.rb +36 -0
  18. data/examples/configuration_examples.rb +68 -0
  19. data/examples/user_model.rb +58 -0
  20. data/lib/generators/nats_wave/install_generator.rb +40 -0
  21. data/lib/generators/nats_wave/templates/README +31 -0
  22. data/lib/generators/nats_wave/templates/create_nats_wave_failed_messages.rb +20 -0
  23. data/lib/generators/nats_wave/templates/create_nats_wave_failed_subscriptions.rb +20 -0
  24. data/lib/generators/nats_wave/templates/initializer.rb +64 -0
  25. data/lib/generators/nats_wave/templates/nats_wave.yml +65 -0
  26. data/lib/nats_wave/adapters/active_record.rb +206 -0
  27. data/lib/nats_wave/adapters/datadog_metrics.rb +93 -0
  28. data/lib/nats_wave/auto_registration.rb +109 -0
  29. data/lib/nats_wave/client.rb +142 -0
  30. data/lib/nats_wave/concerns/mappable.rb +172 -0
  31. data/lib/nats_wave/concerns/publishable.rb +216 -0
  32. data/lib/nats_wave/configuration.rb +105 -0
  33. data/lib/nats_wave/database_connector.rb +50 -0
  34. data/lib/nats_wave/dead_letter_queue.rb +146 -0
  35. data/lib/nats_wave/errors.rb +27 -0
  36. data/lib/nats_wave/message_transformer.rb +95 -0
  37. data/lib/nats_wave/metrics.rb +220 -0
  38. data/lib/nats_wave/middleware/authentication.rb +65 -0
  39. data/lib/nats_wave/middleware/base.rb +19 -0
  40. data/lib/nats_wave/middleware/logging.rb +58 -0
  41. data/lib/nats_wave/middleware/validation.rb +74 -0
  42. data/lib/nats_wave/model_mapper.rb +125 -0
  43. data/lib/nats_wave/model_registry.rb +150 -0
  44. data/lib/nats_wave/publisher.rb +151 -0
  45. data/lib/nats_wave/railtie.rb +111 -0
  46. data/lib/nats_wave/schema_registry.rb +77 -0
  47. data/lib/nats_wave/subscriber.rb +161 -0
  48. data/lib/nats_wave/version.rb +5 -0
  49. data/lib/nats_wave.rb +97 -0
  50. data/lib/tasks/nats_wave.rake +360 -0
  51. data/sig/nats_wave.rbs +5 -0
  52. metadata +385 -0
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWave
4
+ class Metrics
5
+ @metrics_backend = nil
6
+
7
+ class << self
8
+ attr_accessor :metrics_backend
9
+
10
+ # Auto-configure Datadog if available
11
+ def configure_datadog(config = {})
12
+ return unless defined?(Datadog::Statsd)
13
+
14
+ @metrics_backend = Adapters::DatadogMetrics.new({
15
+ service_name: NatsWave.configuration&.service_name || 'nats_wave',
16
+ namespace: 'nats_wave',
17
+ tags: {
18
+ service: NatsWave.configuration&.service_name || 'unknown',
19
+ environment: ENV['RAILS_ENV'] || 'unknown'
20
+ }
21
+ }.merge(config))
22
+
23
+ Rails.logger.info "🐕 NatsWave configured with Datadog metrics" if defined?(Rails)
24
+ end
25
+
26
+ def increment_published_messages(subject, model: nil, action: nil)
27
+ return unless metrics_backend
28
+
29
+ tags = build_tags(subject: subject, model: model, action: action, direction: 'outbound')
30
+ metrics_backend.increment('nats_wave.messages.published', tags: tags)
31
+
32
+ # Also track by model and action separately
33
+ if model && action
34
+ metrics_backend.increment('nats_wave.model.published', tags: ["model:#{model}", "action:#{action}"])
35
+ end
36
+
37
+ log_metric('published', subject, model, action)
38
+ end
39
+
40
+ def increment_received_messages(subject, model: nil, action: nil, source: nil)
41
+ return unless metrics_backend
42
+
43
+ tags = build_tags(subject: subject, model: model, action: action, direction: 'inbound', source: source)
44
+ metrics_backend.increment('nats_wave.messages.received', tags: tags)
45
+
46
+ log_metric('received', subject, model, action)
47
+ end
48
+
49
+ def track_processing_time(subject, model: nil, action: nil, &block)
50
+ return yield unless metrics_backend
51
+
52
+ start_time = Time.current
53
+ result = nil
54
+ success = false
55
+
56
+ begin
57
+ result = yield
58
+ success = true
59
+ result
60
+ rescue => e
61
+ success = false
62
+ increment_errors(e.class.name, subject: subject, model: model)
63
+ raise
64
+ ensure
65
+ duration = Time.current - start_time
66
+
67
+ tags = build_tags(
68
+ subject: subject,
69
+ model: model,
70
+ action: action,
71
+ status: success ? 'success' : 'error'
72
+ )
73
+
74
+ metrics_backend.histogram('nats_wave.processing_time', duration, tags: tags)
75
+
76
+ log_processing_time(subject, duration, success)
77
+ end
78
+ end
79
+
80
+ def increment_errors(error_type, subject: nil, model: nil, action: nil)
81
+ return unless metrics_backend
82
+
83
+ tags = build_tags(
84
+ error_type: error_type,
85
+ subject: subject,
86
+ model: model,
87
+ action: action
88
+ )
89
+
90
+ metrics_backend.increment('nats_wave.errors', tags: tags)
91
+
92
+ # Track specific error types
93
+ metrics_backend.increment('nats_wave.error_types', tags: ["error_type:#{error_type}"])
94
+ end
95
+
96
+ def increment_failed_messages(error_type, subject: nil, retry_count: 0)
97
+ return unless metrics_backend
98
+
99
+ tags = build_tags(error_type: error_type, subject: subject, retry_count: retry_count)
100
+ metrics_backend.increment('nats_wave.messages.failed', tags: tags)
101
+ end
102
+
103
+ def increment_retries(subject: nil, retry_count: 0, max_retries: 3)
104
+ return unless metrics_backend
105
+
106
+ tags = build_tags(
107
+ subject: subject,
108
+ retry_count: retry_count,
109
+ retry_stage: retry_count >= max_retries ? 'final' : 'intermediate'
110
+ )
111
+
112
+ metrics_backend.increment('nats_wave.retries', tags: tags)
113
+ end
114
+
115
+ def gauge_connection_status(connected, nats_url: nil)
116
+ return unless metrics_backend
117
+
118
+ tags = ["status:#{connected ? 'connected' : 'disconnected'}"]
119
+ tags << "server:#{extract_host(nats_url)}" if nats_url
120
+
121
+ metrics_backend.gauge('nats_wave.connection_status', connected ? 1 : 0, tags: tags)
122
+ end
123
+
124
+ def gauge_failed_messages_count(count, queue_type: 'default')
125
+ return unless metrics_backend
126
+
127
+ metrics_backend.gauge('nats_wave.failed_messages_count', count, tags: ["queue:#{queue_type}"])
128
+ end
129
+
130
+ def gauge_subscriber_count(count)
131
+ return unless metrics_backend
132
+
133
+ metrics_backend.gauge('nats_wave.subscriber_count', count)
134
+ end
135
+
136
+ def track_message_size(size_bytes, subject: nil, direction: 'outbound')
137
+ return unless metrics_backend
138
+
139
+ tags = build_tags(subject: subject, direction: direction)
140
+ metrics_backend.histogram('nats_wave.message_size', size_bytes, tags: tags)
141
+ end
142
+
143
+ def track_batch_size(count, operation: 'publish')
144
+ return unless metrics_backend
145
+
146
+ metrics_backend.histogram('nats_wave.batch_size', count, tags: ["operation:#{operation}"])
147
+ end
148
+
149
+ # Custom business metrics
150
+ def track_sync_operation(model, operation, duration, success: true, record_count: 1)
151
+ return unless metrics_backend
152
+
153
+ status = success ? 'success' : 'error'
154
+ tags = ["model:#{model}", "operation:#{operation}", "status:#{status}"]
155
+
156
+ metrics_backend.histogram('nats_wave.sync.duration', duration, tags: tags)
157
+ metrics_backend.histogram('nats_wave.sync.record_count', record_count, tags: tags)
158
+ metrics_backend.increment('nats_wave.sync.operations', tags: tags)
159
+ end
160
+
161
+ private
162
+
163
+ def build_tags(**params)
164
+ tags = []
165
+
166
+ params.each do |key, value|
167
+ next if value.nil? || value.to_s.empty?
168
+
169
+ # Clean up subject names for better grouping
170
+ if key == :subject && value.is_a?(String)
171
+ # Convert "app.events.user.create" to "user.create"
172
+ cleaned_subject = value.split('.').last(2).join('.')
173
+ tags << "subject:#{cleaned_subject}"
174
+
175
+ # Also add subject category
176
+ category = value.split('.').first
177
+ tags << "subject_category:#{category}" if category
178
+ else
179
+ tags << "#{key}:#{value}"
180
+ end
181
+ end
182
+
183
+ tags
184
+ end
185
+
186
+ def extract_host(nats_url)
187
+ return 'unknown' unless nats_url
188
+
189
+ URI.parse(nats_url).host || 'unknown'
190
+ rescue
191
+ 'unknown'
192
+ end
193
+
194
+ def log_metric(type, subject, model = nil, action = nil)
195
+ return unless defined?(Rails)
196
+
197
+ Rails.logger.debug({
198
+ metric: "nats_wave.message.#{type}",
199
+ subject: subject,
200
+ model: model,
201
+ action: action,
202
+ timestamp: Time.current.iso8601
203
+ }.compact.to_json)
204
+ end
205
+
206
+ def log_processing_time(subject, duration, success)
207
+ return unless defined?(Rails)
208
+
209
+ level = success ? :debug : :warn
210
+ Rails.logger.send(level, {
211
+ metric: 'nats_wave.processing_time',
212
+ subject: subject,
213
+ duration_seconds: duration.round(3),
214
+ success: success,
215
+ timestamp: Time.current.iso8601
216
+ }.to_json)
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module NatsWave
6
+ module Middleware
7
+ class Authentication < Base
8
+ def initialize(config)
9
+ super
10
+ @secret_key = config.auth_secret_key
11
+ end
12
+
13
+ def call(message)
14
+ return message unless @config.middleware_authentication_enabled
15
+
16
+ # For outgoing messages, add authentication token
17
+ if outgoing_message?(message)
18
+ add_auth_token(message)
19
+ else
20
+ # For incoming messages, validate token
21
+ validate_auth_token(message)
22
+ end
23
+
24
+ message
25
+ end
26
+
27
+ private
28
+
29
+ def outgoing_message?(message)
30
+ message.key?(:subject) && message.key?(:data)
31
+ end
32
+
33
+ def add_auth_token(message)
34
+ return message unless @secret_key
35
+
36
+ payload = {
37
+ service: @config.service_name,
38
+ instance_id: @config.instance_id,
39
+ timestamp: Time.current.to_i,
40
+ exp: Time.current.to_i + 3600 # 1 hour expiry
41
+ }
42
+
43
+ token = JWT.encode(payload, @secret_key, 'HS256')
44
+ message[:metadata] ||= {}
45
+ message[:metadata][:auth_token] = token
46
+
47
+ message
48
+ end
49
+
50
+ def validate_auth_token(message)
51
+ token = message.dig('metadata', 'auth_token')
52
+
53
+ raise UnauthorizedError, 'Missing authentication token' unless token
54
+
55
+ begin
56
+ JWT.decode(token, @secret_key, true, { algorithm: 'HS256' })
57
+ rescue JWT::DecodeError => e
58
+ raise UnauthorizedError, "Invalid authentication token: #{e.message}"
59
+ end
60
+
61
+ message
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWave
4
+ module Middleware
5
+ class Base
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def call(message)
11
+ raise NotImplementedError, 'Subclasses must implement #call'
12
+ end
13
+
14
+ protected
15
+
16
+ attr_reader :config
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWave
4
+ module Middleware
5
+ class Logging < Base
6
+ def call(message)
7
+ return message unless logging_enabled?
8
+
9
+ if outgoing_message?(message)
10
+ log_outgoing_message(message)
11
+ else
12
+ log_incoming_message(message)
13
+ end
14
+
15
+ message
16
+ end
17
+
18
+ private
19
+
20
+ def logging_enabled?
21
+ @config.middleware_logging_enabled
22
+ end
23
+
24
+ def outgoing_message?(message)
25
+ message.key?(:subject) && message.key?(:data)
26
+ end
27
+
28
+ def log_outgoing_message(message)
29
+ NatsWave.logger.send(log_level, {
30
+ type: 'nats_wave_publish',
31
+ event_id: message[:event_id],
32
+ subject: message[:subject],
33
+ model: message[:model],
34
+ action: message[:action],
35
+ service: @config.service_name,
36
+ timestamp: message[:timestamp]
37
+ }.to_json)
38
+ end
39
+
40
+ def log_incoming_message(message)
41
+ NatsWave.logger.send(log_level, {
42
+ type: 'nats_wave_receive',
43
+ event_id: message['event_id'],
44
+ subject: message['subject'],
45
+ model: message['model'],
46
+ action: message['action'],
47
+ source_service: message.dig('source', 'service'),
48
+ timestamp: message['timestamp']
49
+ }.to_json)
50
+ end
51
+
52
+ def log_level
53
+ level = @config.log_level || 'info'
54
+ level.to_sym
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json-schema'
4
+
5
+ module NatsWave
6
+ module Middleware
7
+ class Validation < Base
8
+ MESSAGE_SCHEMA = {
9
+ type: 'object',
10
+ required: %w[event_id timestamp source subject model action data],
11
+ properties: {
12
+ event_id: {
13
+ type: 'string',
14
+ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
15
+ },
16
+ timestamp: { type: 'string', format: 'date-time' },
17
+ source: {
18
+ type: 'object',
19
+ required: %w[service version instance_id],
20
+ properties: {
21
+ service: { type: 'string' },
22
+ version: { type: 'string' },
23
+ instance_id: { type: 'string' }
24
+ }
25
+ },
26
+ subject: { type: 'string', minLength: 1 },
27
+ model: { type: 'string', minLength: 1 },
28
+ action: { type: 'string', enum: %w[create update delete destroy] },
29
+ data: { type: 'object' },
30
+ metadata: { type: 'object' },
31
+ schema_version: { type: 'string' }
32
+ }
33
+ }.freeze
34
+
35
+ def initialize(config)
36
+ super
37
+ @schema_registry = SchemaRegistry.new(config.schema_registry_url) if validation_enabled?
38
+ end
39
+
40
+ def call(message)
41
+ return message unless validation_enabled?
42
+
43
+ validate_message_structure(message)
44
+ validate_against_schema(message) if @schema_registry
45
+
46
+ message
47
+ end
48
+
49
+ private
50
+
51
+ def validation_enabled?
52
+ @config.middleware_validation_enabled
53
+ end
54
+
55
+ def validate_message_structure(message)
56
+ errors = JSON::Validator.fully_validate(MESSAGE_SCHEMA, message)
57
+
58
+ return if errors.empty?
59
+
60
+ error_message = "Message validation failed: #{errors.join(', ')}"
61
+ NatsWave.logger.error(error_message)
62
+ raise ValidationError, error_message
63
+ end
64
+
65
+ def validate_against_schema(message)
66
+ schema_version = message['schema_version'] || '1.0'
67
+
68
+ return if @schema_registry.validate_message(message, schema_version)
69
+
70
+ raise ValidationError, "Message does not conform to schema version #{schema_version}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWave
4
+ class ModelMapper
5
+ def initialize(config = nil)
6
+ @config = config
7
+ end
8
+
9
+ def map_external_to_local(external_model, external_data)
10
+ # First check the registry
11
+ local_model = ModelRegistry.best_local_model_for(external_model, external_data)
12
+ return nil unless local_model
13
+
14
+ local_mappings = ModelRegistry.local_models_for(external_model)
15
+ mapping_config = local_mappings[local_model]
16
+
17
+ # Transform the data
18
+ transformed_data = transform_data(
19
+ external_data,
20
+ mapping_config[:field_mappings] || {},
21
+ mapping_config[:transformations] || {}
22
+ )
23
+
24
+ {
25
+ model: local_model,
26
+ data: transformed_data,
27
+ config: mapping_config,
28
+ unique_fields: mapping_config[:unique_fields] || [:id],
29
+ sync_strategy: mapping_config[:sync_strategy] || :upsert
30
+ }
31
+ end
32
+
33
+ def map_local_to_external(local_model_instance, target_external_model)
34
+ return nil unless local_model_instance.class.respond_to?(:nats_wave_can_sync_from?)
35
+ return nil unless local_model_instance.class.nats_wave_can_sync_from?(target_external_model)
36
+
37
+ local_model_instance.nats_wave_mapped_attributes_for(target_external_model)
38
+ end
39
+
40
+ def transform_data(data, field_mappings, transformations)
41
+ result = {}
42
+
43
+ # Apply field mappings
44
+ data.each do |key, value|
45
+ mapped_key = field_mappings[key] || field_mappings[key.to_sym] || key
46
+ result[mapped_key] = value
47
+ end
48
+
49
+ # Apply transformations
50
+ transformations.each do |field, transformer|
51
+ if result.key?(field) && transformer.respond_to?(:call)
52
+ begin
53
+ result[field] = transformer.call(result[field])
54
+ rescue => e
55
+ NatsWave.logger.error("Transformation failed for field #{field}: #{e.message}")
56
+ raise TransformationError, "Failed to transform field #{field}: #{e.message}"
57
+ end
58
+ end
59
+ end
60
+
61
+ result
62
+ end
63
+
64
+ # Legacy compatibility with old configuration-based mappings
65
+ def map_model(external_model, external_data)
66
+ # Try registry first
67
+ registry_mapping = map_external_to_local(external_model, external_data)
68
+ return registry_mapping if registry_mapping
69
+
70
+ # Fall back to configuration-based mapping
71
+ return nil unless @config
72
+
73
+ mapping_config = @config.model_mappings[external_model]
74
+ return nil unless mapping_config
75
+
76
+ local_model = mapping_config[:target_model]
77
+ field_mappings = mapping_config[:field_mappings] || {}
78
+
79
+ mapped_attributes = map_attributes(external_data, field_mappings)
80
+
81
+ {
82
+ model: local_model,
83
+ id: extract_id(external_data, field_mappings),
84
+ attributes: mapped_attributes
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def map_attributes(external_data, field_mappings)
91
+ mapped = {}
92
+
93
+ external_data.each do |external_field, value|
94
+ local_field = field_mappings[external_field] || external_field
95
+
96
+ # Apply transformations if configured
97
+ if @config&.transformation_rules && @config.transformation_rules[local_field]
98
+ value = apply_transformation(local_field, value)
99
+ end
100
+
101
+ mapped[local_field] = value
102
+ end
103
+
104
+ mapped
105
+ end
106
+
107
+ def extract_id(external_data, field_mappings)
108
+ id_field = field_mappings['id'] || 'id'
109
+ external_data[id_field]
110
+ end
111
+
112
+ def apply_transformation(field, value)
113
+ transformation = @config.transformation_rules[field]
114
+
115
+ case transformation
116
+ when Proc
117
+ transformation.call(value)
118
+ when Symbol
119
+ send(transformation, value) if respond_to?(transformation, true)
120
+ else
121
+ value
122
+ end
123
+ end
124
+ end
125
+ end