nats_wave 1.1.13 β 1.1.15
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.
- checksums.yaml +4 -4
- data/.idea/nats_wave.iml +5 -5
- data/Gemfile.lock +1 -1
- data/README.md +934 -1246
- data/lib/generators/nats_wave/templates/initializer.rb +14 -14
- data/lib/nats_wave/adapters/datadog_metrics.rb +1 -1
- data/lib/nats_wave/auto_registration.rb +10 -10
- data/lib/nats_wave/client.rb +0 -764
- data/lib/nats_wave/concerns/mappable.rb +380 -681
- data/lib/nats_wave/configuration.rb +1 -1
- data/lib/nats_wave/metrics.rb +3 -3
- data/lib/nats_wave/model_registry.rb +3 -3
- data/lib/nats_wave/railtie.rb +2 -2
- data/lib/nats_wave/subscriber.rb +2 -2
- data/lib/nats_wave/version.rb +1 -1
- data/lib/nats_wave.rb +0 -99
- metadata +2 -2
data/README.md
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
# π NatsWave
|
2
2
|
|
3
|
-
A comprehensive NATS-based event messaging system for Rails applications that enables seamless communication between
|
4
|
-
different teams, products, and services in asset management and valuation workflows.
|
3
|
+
A comprehensive NATS-based event messaging system for Rails applications that enables seamless communication between different teams, products, and services in asset management and valuation workflows.
|
5
4
|
|
6
5
|
[](https://badge.fury.io/rb/nats_wave)
|
7
6
|
[](https://github.com/PurpleWave/nats_wave/actions)
|
@@ -31,18 +30,17 @@ different teams, products, and services in asset management and valuation workfl
|
|
31
30
|
|
32
31
|
## β¨ Features
|
33
32
|
|
34
|
-
- **π Model
|
35
|
-
-
|
36
|
-
-
|
37
|
-
|
38
|
-
- **π Cross-Service Synchronization** -
|
39
|
-
- **π―
|
33
|
+
- **π Streamlined Model Configuration** - Simple, declarative model-based subscriptions and publishing
|
34
|
+
- **π€ Smart Publishing** - Automatic event publishing with custom field mappings and transformations
|
35
|
+
- **π₯ Intelligent Subscriptions** - Auto-sync with custom handlers for complex processing
|
36
|
+
- **πΊοΈ Advanced Data Mapping** - Transform data between different schemas with field mappings and transformations
|
37
|
+
- **π Cross-Service Synchronization** - Seamlessly sync data between services with different models and schemas
|
38
|
+
- **π― Custom Message Handlers** - Optional custom processing after automatic sync operations
|
40
39
|
- **π Datadog Integration** - Built-in metrics and monitoring support
|
41
40
|
- **π‘οΈ Robust Error Handling** - Dead letter queues, retries, and graceful failure handling
|
42
41
|
- **π§ Middleware System** - Authentication, validation, and logging middleware
|
43
42
|
- **β‘ High Performance** - Connection pooling, async publishing, and batch operations
|
44
|
-
-
|
45
|
-
- **π Auto-Discovery** - Automatic registration of model subscriptions via ModelRegistry
|
43
|
+
- **π Auto-Reconnection** - Automatic reconnection with exponential backoff and health monitoring
|
46
44
|
- **π§ͺ Test-Friendly** - Comprehensive test helpers and mocking support
|
47
45
|
|
48
46
|
## π¦ Installation
|
@@ -51,6 +49,7 @@ Add this line to your Rails application's Gemfile:
|
|
51
49
|
|
52
50
|
```ruby
|
53
51
|
gem 'nats_wave'
|
52
|
+
gem 'nats', '~> 0.11' # NATS Ruby client
|
54
53
|
|
55
54
|
# Optional: For enhanced monitoring
|
56
55
|
gem 'dogstatsd-ruby' # For Datadog metrics
|
@@ -66,7 +65,6 @@ Generate the configuration files:
|
|
66
65
|
|
67
66
|
```bash
|
68
67
|
rails generate nats_wave:install
|
69
|
-
rails db:migrate
|
70
68
|
```
|
71
69
|
|
72
70
|
## π Quick Start
|
@@ -76,17 +74,16 @@ rails db:migrate
|
|
76
74
|
```ruby
|
77
75
|
# config/initializers/nats_wave.rb
|
78
76
|
NatsWave.configure do |config|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
config.queue_group = "#{config.service_name}_consumers"
|
77
|
+
config.nats_url = ENV.fetch('NATS_URL', 'nats://localhost:4222')
|
78
|
+
config.service_name = ENV.fetch('NATS_SERVICE_NAME', 'valuation_service')
|
79
|
+
|
80
|
+
# Publishing
|
81
|
+
config.publishing_enabled = !Rails.env.test?
|
82
|
+
config.async_publishing = Rails.env.production?
|
83
|
+
|
84
|
+
# Subscription
|
85
|
+
config.subscription_enabled = !Rails.env.test?
|
86
|
+
config.queue_group = "#{config.service_name}_consumers"
|
90
87
|
end
|
91
88
|
```
|
92
89
|
|
@@ -95,78 +92,86 @@ end
|
|
95
92
|
```ruby
|
96
93
|
# app/models/asset.rb
|
97
94
|
class Asset < ApplicationRecord
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
95
|
+
include NatsWave::Concerns::Mappable
|
96
|
+
|
97
|
+
# Subscribe to external inventory system - auto-sync + custom handler
|
98
|
+
nats_wave_subscribes_to "inventory_service.items.*",
|
99
|
+
field_mappings: {
|
100
|
+
'item_id' => 'unique_id',
|
101
|
+
'description' => 'description',
|
102
|
+
'manufacturer' => 'make',
|
103
|
+
'model_name' => 'model',
|
104
|
+
'year_manufactured' => 'year',
|
105
|
+
'serial_number' => 'serial'
|
106
|
+
},
|
107
|
+
transformations: {
|
108
|
+
'make' => ->(make) { make&.upcase&.strip },
|
109
|
+
'year' => ->(year) { year.to_i if year.present? }
|
110
|
+
},
|
111
|
+
unique_fields: [:unique_id],
|
112
|
+
skip_fields: ['internal_notes'] do |model_name, action, processed_data, message|
|
113
|
+
# Custom handler runs AFTER auto-sync
|
114
|
+
Rails.logger.info "π¨ Processed #{action} for #{model_name}: #{processed_data[:unique_id]}"
|
115
|
+
|
116
|
+
# Custom logic here
|
117
|
+
if action == 'create' && processed_data[:estimated_value].blank?
|
118
|
+
ValuationRequestJob.perform_later(processed_data[:unique_id])
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Publish asset changes to other services
|
123
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.assets.{action}",
|
124
|
+
field_mappings: {
|
125
|
+
'unique_id' => 'asset_id',
|
126
|
+
'make' => 'manufacturer',
|
127
|
+
'model' => 'model_name'
|
128
|
+
},
|
129
|
+
transformations: {
|
130
|
+
'created_at' => lambda(&:iso8601),
|
131
|
+
'updated_at' => lambda(&:iso8601),
|
132
|
+
'estimated_hammer' => ->(cents) { cents.to_f / 100 }
|
133
|
+
},
|
134
|
+
skip_fields: %w[notes edge_data source_data],
|
135
|
+
actions: %i[create update] do |model_name, action, published_data, record|
|
136
|
+
# Custom handler runs AFTER publishing
|
137
|
+
Rails.logger.info "π€ Published #{action} for #{model_name}: #{record.id}"
|
138
|
+
|
139
|
+
# Send notifications, update caches, etc.
|
140
|
+
if action == 'update' && record.valuation_complete?
|
141
|
+
NotificationService.notify_valuation_complete(record)
|
142
|
+
end
|
132
143
|
end
|
133
144
|
end
|
134
145
|
```
|
135
146
|
|
136
147
|
### 3. Start the Subscriber
|
137
148
|
|
138
|
-
The subscriber automatically starts with Rails
|
149
|
+
The subscriber automatically starts with Rails:
|
139
150
|
|
140
151
|
```bash
|
141
|
-
# Development - auto-starts with Rails
|
152
|
+
# Development - auto-starts with Rails server
|
142
153
|
rails server
|
143
154
|
|
144
155
|
# You'll see logs like:
|
145
|
-
# I, [2025-
|
146
|
-
# I, [2025-
|
147
|
-
# I, [2025-
|
156
|
+
# I, [2025-01-21T12:10:35.910694 #29] INFO -- : Starting NATS subscriber for valuation_service
|
157
|
+
# I, [2025-01-21T12:10:35.911316 #29] INFO -- : β
Successfully subscribed to inventory_service.items.*
|
158
|
+
# I, [2025-01-21T12:10:35.912749 #29] INFO -- : Started 3 subscriptions from Model Registry
|
148
159
|
|
149
|
-
# Production - run dedicated subscriber process
|
150
|
-
|
160
|
+
# Production - run dedicated subscriber process if needed
|
161
|
+
bundle exec rake nats_wave:start
|
151
162
|
```
|
152
163
|
|
153
164
|
### 4. Test the Integration
|
154
165
|
|
155
166
|
```bash
|
156
167
|
# Test connectivity
|
157
|
-
rails
|
158
|
-
|
159
|
-
# Show model subscriptions
|
160
|
-
rails nats_wave:model_subscriptions
|
168
|
+
rails runner "puts NatsWave.health_check"
|
161
169
|
|
162
170
|
# Publish a test message
|
163
171
|
rails console
|
164
|
-
>
|
165
|
-
|
166
|
-
|
167
|
-
action: 'create',
|
168
|
-
data: { id: 123, make: 'Caterpillar', model: '320D' }
|
169
|
-
)
|
172
|
+
> asset = Asset.first
|
173
|
+
> asset.update!(make: 'Updated Manufacturer')
|
174
|
+
# This automatically publishes via nats_wave_publishes_to
|
170
175
|
```
|
171
176
|
|
172
177
|
## βοΈ Configuration
|
@@ -176,13 +181,10 @@ rails console
|
|
176
181
|
```bash
|
177
182
|
# Required
|
178
183
|
NATS_URL=nats://your-nats-server:4222
|
179
|
-
|
180
|
-
# Optional - defaults to Rails application name
|
181
184
|
NATS_SERVICE_NAME=valuation_service
|
182
185
|
|
183
186
|
# Optional Authentication & Security
|
184
187
|
NATS_AUTH_SECRET=your-secret-key
|
185
|
-
SCHEMA_REGISTRY_URL=http://schema-registry:8081
|
186
188
|
|
187
189
|
# Optional Monitoring
|
188
190
|
DD_AGENT_HOST=localhost # For Datadog metrics
|
@@ -191,484 +193,251 @@ DD_AGENT_PORT=8125
|
|
191
193
|
|
192
194
|
### Complete Configuration Setup
|
193
195
|
|
194
|
-
Create or update `config/initializers/nats_wave.rb`:
|
195
|
-
|
196
196
|
```ruby
|
197
|
-
#
|
198
|
-
|
197
|
+
# config/initializers/nats_wave.rb
|
199
198
|
begin
|
200
|
-
|
199
|
+
Rails.logger.info "Initializing NATS Wave..."
|
200
|
+
|
201
|
+
NatsWave.configure do |config|
|
202
|
+
# === CORE CONNECTION SETTINGS ===
|
203
|
+
config.nats_url = ENV.fetch('NATS_URL', 'nats://localhost:4222')
|
204
|
+
config.service_name = ENV.fetch('NATS_SERVICE_NAME',
|
205
|
+
Rails.application.class.name.deconstantize.underscore)
|
206
|
+
config.connection_pool_size = 10
|
207
|
+
config.reconnect_attempts = 3
|
201
208
|
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
config.subscription_enabled = !Rails.env.test? # Disable in test environment
|
220
|
-
config.queue_group = "#{config.service_name}_consumers" # Queue group for load balancing
|
221
|
-
|
222
|
-
# === ERROR HANDLING ===
|
223
|
-
config.max_retries = 3 # Max retry attempts
|
224
|
-
config.retry_delay = 5 # Base retry delay in seconds
|
225
|
-
config.dead_letter_queue = "failed_messages" # Failed message storage
|
226
|
-
|
227
|
-
# === AUTHENTICATION (Optional) ===
|
228
|
-
if ENV['NATS_AUTH_SECRET'].present?
|
229
|
-
config.middleware_authentication_enabled = true
|
230
|
-
config.auth_secret_key = ENV['NATS_AUTH_SECRET']
|
231
|
-
end
|
232
|
-
|
233
|
-
# === VALIDATION (Optional) ===
|
234
|
-
if ENV['SCHEMA_REGISTRY_URL'].present?
|
235
|
-
config.middleware_validation_enabled = true
|
236
|
-
config.schema_registry_url = ENV['SCHEMA_REGISTRY_URL']
|
237
|
-
end
|
238
|
-
|
239
|
-
# === LOGGING ===
|
240
|
-
config.middleware_logging_enabled = true
|
241
|
-
config.log_level = Rails.env.production? ? 'info' : 'debug'
|
209
|
+
# === PUBLISHING CONFIGURATION ===
|
210
|
+
config.publishing_enabled = !Rails.env.test?
|
211
|
+
config.async_publishing = Rails.env.production?
|
212
|
+
|
213
|
+
# === SUBSCRIPTION CONFIGURATION ===
|
214
|
+
config.subscription_enabled = !Rails.env.test?
|
215
|
+
config.queue_group = "#{config.service_name}_consumers"
|
216
|
+
|
217
|
+
# === ERROR HANDLING ===
|
218
|
+
config.max_retries = 3
|
219
|
+
config.retry_delay = 5
|
220
|
+
config.dead_letter_queue = "failed_messages"
|
221
|
+
|
222
|
+
# === AUTHENTICATION (Optional) ===
|
223
|
+
if ENV['NATS_AUTH_SECRET'].present?
|
224
|
+
config.middleware_authentication_enabled = true
|
225
|
+
config.auth_secret_key = ENV['NATS_AUTH_SECRET']
|
242
226
|
end
|
243
227
|
|
244
|
-
|
228
|
+
# === LOGGING ===
|
229
|
+
config.middleware_logging_enabled = true
|
230
|
+
config.log_level = Rails.env.production? ? 'info' : 'debug'
|
231
|
+
end
|
232
|
+
|
233
|
+
Rails.logger.info "β
NATS Wave configuration completed successfully"
|
245
234
|
rescue => e
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
# Optional: Re-raise in development to catch configuration issues early
|
250
|
-
raise e if Rails.env.development?
|
235
|
+
Rails.logger.error "β Failed to configure NATS Wave: #{e.message}"
|
236
|
+
raise e if Rails.env.development?
|
251
237
|
end
|
252
238
|
|
253
239
|
# === AUTOMATIC SUBSCRIBER STARTUP ===
|
254
240
|
Rails.application.config.after_initialize do
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
Rails.logger.info "π Active subscriptions: #{stats[:total_subscriptions]} across #{stats[:models_with_subscriptions]} models"
|
265
|
-
|
266
|
-
rescue StandardError => e
|
267
|
-
Rails.logger.error "Failed to start NatsWave subscriber: #{e.message}"
|
268
|
-
Rails.logger.error e.backtrace.join("\n")
|
269
|
-
|
270
|
-
# Optional: Alert monitoring systems
|
271
|
-
if defined?(Sentry)
|
272
|
-
Sentry.capture_exception(e, tags: { component: 'nats_wave_startup' })
|
273
|
-
end
|
274
|
-
end
|
275
|
-
end
|
276
|
-
end
|
277
|
-
|
278
|
-
# === DATADOG METRICS INTEGRATION (Optional) ===
|
279
|
-
if defined?(Datadog::Statsd) && !Rails.env.test?
|
280
|
-
# Get application version safely
|
281
|
-
app_version = begin
|
282
|
-
Rails.application.config.version
|
283
|
-
rescue
|
284
|
-
'unknown'
|
285
|
-
end
|
241
|
+
Thread.new do
|
242
|
+
sleep 2 # Give Rails time to boot and load all models
|
243
|
+
begin
|
244
|
+
NatsWave.start_subscriber
|
245
|
+
Rails.logger.info "β
NatsWave subscriber started successfully"
|
246
|
+
|
247
|
+
# Log subscription statistics
|
248
|
+
stats = NatsWave::ModelRegistry.subscription_stats
|
249
|
+
Rails.logger.info "π Active subscriptions: #{stats[:total_subscriptions]} across #{stats[:models_with_subscriptions]} models"
|
286
250
|
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
)
|
295
|
-
Rails.logger.info "π Datadog metrics integration enabled"
|
251
|
+
rescue StandardError => e
|
252
|
+
Rails.logger.error "β Failed to start NatsWave subscriber: #{e.message}"
|
253
|
+
|
254
|
+
# Optional: Alert monitoring systems
|
255
|
+
Sentry.capture_exception(e) if defined?(Sentry)
|
256
|
+
end
|
257
|
+
end
|
296
258
|
end
|
297
259
|
|
298
|
-
# === GRACEFUL SHUTDOWN
|
260
|
+
# === GRACEFUL SHUTDOWN ===
|
299
261
|
at_exit do
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
end
|
306
|
-
rescue => e
|
307
|
-
Rails.logger.error "Error during NatsWave shutdown: #{e.message}"
|
262
|
+
begin
|
263
|
+
if defined?(NatsWave) && NatsWave.client
|
264
|
+
Rails.logger.info "π Shutting down NatsWave connections..."
|
265
|
+
NatsWave.client.disconnect!
|
266
|
+
Rails.logger.info "β
NatsWave shutdown complete"
|
308
267
|
end
|
268
|
+
rescue => e
|
269
|
+
Rails.logger.error "Error during NatsWave shutdown: #{e.message}"
|
270
|
+
end
|
309
271
|
end
|
310
272
|
```
|
311
273
|
|
312
|
-
|
274
|
+
## ποΈ Model-Based Architecture
|
313
275
|
|
314
|
-
|
276
|
+
NatsWave uses a streamlined model-centric approach where each model defines its own subscriptions and publishing through the **ModelRegistry** system.
|
315
277
|
|
316
|
-
|
317
|
-
# config/environments/development.rb
|
318
|
-
config.after_initialize do
|
319
|
-
if defined?(NatsWave)
|
320
|
-
# Enable debug logging in development
|
321
|
-
NatsWave.logger.level = Logger::DEBUG
|
322
|
-
|
323
|
-
# Optional: Show all registered subscriptions on startup
|
324
|
-
if NatsWave::ModelRegistry.subscriptions.any?
|
325
|
-
Rails.logger.info "π Registered NATS subscriptions:"
|
326
|
-
NatsWave::ModelRegistry.subscriptions.each do |subscription|
|
327
|
-
Rails.logger.info " π‘ #{subscription[:model]} -> #{subscription[:subjects].join(', ')}"
|
328
|
-
end
|
329
|
-
end
|
330
|
-
end
|
331
|
-
end
|
332
|
-
```
|
278
|
+
### Core Concepts
|
333
279
|
|
334
|
-
|
280
|
+
- **Auto-Sync**: Automatic field mapping and database synchronization
|
281
|
+
- **Custom Handlers**: Optional custom processing after auto-sync or publishing
|
282
|
+
- **Field Mappings**: Transform field names between external and local models
|
283
|
+
- **Transformations**: Apply data transformations during sync/publish
|
284
|
+
- **Unique Fields**: Define how to find existing records for updates
|
335
285
|
|
336
|
-
|
337
|
-
# config/environments/production.rb
|
338
|
-
config.after_initialize do
|
339
|
-
if defined?(NatsWave)
|
340
|
-
# Enable authentication in production
|
341
|
-
NatsWave.configuration.middleware_authentication_enabled = true
|
342
|
-
|
343
|
-
# Set production logging level
|
344
|
-
NatsWave.logger.level = Logger::INFO
|
345
|
-
|
346
|
-
# Enable health check endpoint
|
347
|
-
Rails.application.routes.draw do
|
348
|
-
get '/health/nats_wave', to: 'health#nats_wave'
|
349
|
-
end
|
350
|
-
end
|
351
|
-
end
|
352
|
-
```
|
286
|
+
## π€ Publishing Events
|
353
287
|
|
354
|
-
|
288
|
+
### Automatic Publishing with Custom Processing
|
355
289
|
|
356
290
|
```ruby
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
291
|
+
class Package < ApplicationRecord
|
292
|
+
include NatsWave::Concerns::Mappable
|
293
|
+
|
294
|
+
# Publish package events with field mapping and custom processing
|
295
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.packages.{action}",
|
296
|
+
field_mappings: {
|
297
|
+
'id' => 'package_id',
|
298
|
+
'key' => 'package_key',
|
299
|
+
'owner_name' => 'client_name',
|
300
|
+
'workflow_status' => 'status'
|
301
|
+
},
|
302
|
+
transformations: {
|
303
|
+
'created_at' => lambda(&:iso8601),
|
304
|
+
'updated_at' => lambda(&:iso8601),
|
305
|
+
'workflow_status' => ->(status) { status&.upcase }
|
306
|
+
},
|
307
|
+
skip_fields: %w[notes source_data edge_data],
|
308
|
+
actions: %i[create update] do |model_name, action, published_data, record|
|
309
|
+
# Custom processing AFTER publishing
|
310
|
+
Rails.logger.info "π€ Published #{action} for #{model_name}: #{record.key}"
|
311
|
+
|
312
|
+
# Notify related services
|
313
|
+
if action == 'update' && record.workflow_status_changed?
|
314
|
+
WorkflowNotificationService.notify_status_change(record)
|
315
|
+
end
|
316
|
+
|
317
|
+
# Update search indexes
|
318
|
+
SearchIndexUpdateJob.perform_later(record.id, 'Package') if action == 'update'
|
370
319
|
end
|
371
320
|
end
|
372
321
|
```
|
373
322
|
|
374
|
-
###
|
375
|
-
|
376
|
-
| Option | Default | Description |
|
377
|
-
|-------------------------------------|-------------------------|---------------------------------|
|
378
|
-
| `nats_url` | `nats://localhost:4222` | NATS server connection URL |
|
379
|
-
| `service_name` | Rails app name | Unique service identifier |
|
380
|
-
| `connection_pool_size` | `10` | Max concurrent NATS connections |
|
381
|
-
| `reconnect_attempts` | `3` | Auto-reconnect attempts |
|
382
|
-
| `publishing_enabled` | `!Rails.env.test?` | Enable/disable publishing |
|
383
|
-
| `async_publishing` | `Rails.env.production?` | Publish asynchronously |
|
384
|
-
| `subscription_enabled` | `!Rails.env.test?` | Enable/disable subscriptions |
|
385
|
-
| `queue_group` | `{service}_consumers` | NATS queue group name |
|
386
|
-
| `max_retries` | `3` | Failed message retry attempts |
|
387
|
-
| `retry_delay` | `5` | Base retry delay (seconds) |
|
388
|
-
| `middleware_authentication_enabled` | `false` | Enable auth middleware |
|
389
|
-
| `middleware_validation_enabled` | `false` | Enable validation middleware |
|
390
|
-
| `middleware_logging_enabled` | `true` | Enable logging middleware |
|
391
|
-
|
392
|
-
### Health Check Endpoint
|
393
|
-
|
394
|
-
Add a health check endpoint to monitor NatsWave status:
|
323
|
+
### Dynamic Subject Generation
|
395
324
|
|
396
325
|
```ruby
|
397
|
-
#
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
#
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
if health_data[:nats_connected]
|
409
|
-
render json: health_data, status: :ok
|
410
|
-
else
|
411
|
-
render json: health_data.merge(error: 'NATS not connected'), status: :service_unavailable
|
412
|
-
end
|
413
|
-
rescue => e
|
414
|
-
render json: {
|
415
|
-
error: e.message,
|
416
|
-
nats_connected: false,
|
417
|
-
timestamp: Time.current.iso8601
|
418
|
-
}, status: :service_unavailable
|
419
|
-
end
|
420
|
-
end
|
421
|
-
```
|
422
|
-
|
423
|
-
### Docker Environment Configuration
|
424
|
-
|
425
|
-
```yaml
|
426
|
-
# docker-compose.yml
|
427
|
-
version: '3.8'
|
428
|
-
services:
|
429
|
-
nats:
|
430
|
-
image: nats:latest
|
431
|
-
ports:
|
432
|
-
- "4222:4222"
|
433
|
-
- "8222:8222" # Management interface
|
434
|
-
command: "--http_port 8222 --js"
|
435
|
-
|
436
|
-
app:
|
437
|
-
build: .
|
438
|
-
depends_on:
|
439
|
-
- nats
|
440
|
-
- postgres
|
441
|
-
- redis
|
442
|
-
environment:
|
443
|
-
- NATS_URL=nats://nats:4222
|
444
|
-
- NATS_SERVICE_NAME=valuation_service
|
445
|
-
- NATS_AUTH_SECRET=your-secret-key
|
446
|
-
- DD_AGENT_HOST=datadog-agent
|
447
|
-
ports:
|
448
|
-
- "3000:3000"
|
449
|
-
healthcheck:
|
450
|
-
test: [ "CMD", "curl", "-f", "http://localhost:3000/health/nats_wave" ]
|
451
|
-
interval: 30s
|
452
|
-
timeout: 10s
|
453
|
-
retries: 3
|
454
|
-
start_period: 40s
|
455
|
-
```
|
456
|
-
|
457
|
-
### Kubernetes ConfigMap
|
458
|
-
|
459
|
-
```yaml
|
460
|
-
# k8s/nats-wave-config.yaml
|
461
|
-
apiVersion: v1
|
462
|
-
kind: ConfigMap
|
463
|
-
metadata:
|
464
|
-
name: nats-wave-config
|
465
|
-
data:
|
466
|
-
NATS_URL: "nats://nats-cluster:4222"
|
467
|
-
NATS_SERVICE_NAME: "valuation-service"
|
468
|
-
---
|
469
|
-
apiVersion: v1
|
470
|
-
kind: Secret
|
471
|
-
metadata:
|
472
|
-
name: nats-wave-secrets
|
473
|
-
type: Opaque
|
474
|
-
data:
|
475
|
-
NATS_AUTH_SECRET: <base64-encoded-secret>
|
326
|
+
# Use {action} placeholder for dynamic subjects
|
327
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.assets.{action}"
|
328
|
+
# Creates: valuation_service.assets.create, valuation_service.assets.update, etc.
|
329
|
+
|
330
|
+
# Multiple subject patterns
|
331
|
+
nats_wave_publishes_to [
|
332
|
+
"#{ENV['NATS_SERVICE_NAME']}.assets.{action}",
|
333
|
+
"global.assets.{action}",
|
334
|
+
"audit.asset_changes"
|
335
|
+
]
|
476
336
|
```
|
477
337
|
|
478
|
-
|
479
|
-
|
480
|
-
NatsWave uses a model-centric approach where each model defines its own subscriptions and mappings through the *
|
481
|
-
*ModelRegistry** system.
|
482
|
-
|
483
|
-
### Publishing Events
|
338
|
+
### Conditional Publishing
|
484
339
|
|
485
340
|
```ruby
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
private
|
500
|
-
|
501
|
-
# Add custom metadata to published events
|
502
|
-
def nats_wave_metadata
|
503
|
-
{
|
504
|
-
organization_name: organization&.name,
|
505
|
-
asset_count: assets.count,
|
506
|
-
total_estimated_value: assets.sum(&:estimated_hammer),
|
507
|
-
valuation_complete: valuation_complete?
|
508
|
-
}
|
341
|
+
nats_wave_publishes_to "inventory.assets.{action}",
|
342
|
+
actions: %i[create update],
|
343
|
+
# Add conditions in the custom handler
|
344
|
+
skip_fields: %w[internal_notes] do |model_name, action, published_data, record|
|
345
|
+
# Only process if certain conditions are met
|
346
|
+
if record.asset_status == 'active' && !record.is_internal?
|
347
|
+
Rails.logger.info "π€ Published #{action} for active asset: #{record.id}"
|
348
|
+
|
349
|
+
# Custom processing here
|
350
|
+
ExternalInventoryService.sync_asset(record)
|
351
|
+
else
|
352
|
+
Rails.logger.debug "βοΈ Skipped publishing for #{record.id} (inactive or internal)"
|
509
353
|
end
|
510
|
-
end
|
354
|
+
end
|
511
355
|
```
|
512
356
|
|
513
|
-
|
357
|
+
## π₯ Subscribing to Events
|
514
358
|
|
515
|
-
|
359
|
+
### Auto-Sync with Custom Processing
|
516
360
|
|
361
|
+
```ruby
|
517
362
|
class Asset < ApplicationRecord
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
363
|
+
include NatsWave::Concerns::Mappable
|
364
|
+
|
365
|
+
# Subscribe to inventory system - auto-sync + custom processing
|
366
|
+
nats_wave_subscribes_to "inventory_service.items.*", "warehouse_service.assets.*",
|
367
|
+
field_mappings: {
|
368
|
+
'item_id' => 'unique_id',
|
369
|
+
'description' => 'description',
|
370
|
+
'manufacturer' => 'make',
|
371
|
+
'model_name' => 'model',
|
372
|
+
'year_manufactured' => 'year',
|
373
|
+
'serial_number' => 'serial',
|
374
|
+
'vin_number' => 'vin',
|
375
|
+
'estimated_value' => 'estimated_hammer'
|
376
|
+
},
|
377
|
+
transformations: {
|
378
|
+
'make' => ->(make) { make&.upcase&.strip },
|
379
|
+
'model' => ->(model) { model&.titleize&.strip },
|
380
|
+
'year' => ->(year) { year.to_i if year.present? },
|
381
|
+
'estimated_hammer' => ->(value) { (value.to_f * 100).round }, # Convert to cents
|
382
|
+
'asset_status' => ->(status) { status&.downcase || 'active' }
|
383
|
+
},
|
384
|
+
unique_fields: [:unique_id, :vin, :serial],
|
385
|
+
skip_fields: ['internal_inventory_code', 'warehouse_location'] do |model_name, action, processed_data, message|
|
386
|
+
# Custom handler runs AFTER auto-sync
|
387
|
+
Rails.logger.info "π¨ Auto-synced #{action} for #{model_name}: #{processed_data[:unique_id]}"
|
388
|
+
|
389
|
+
case action
|
390
|
+
when 'create'
|
391
|
+
# Request valuation for new assets
|
392
|
+
if processed_data[:estimated_hammer].blank?
|
393
|
+
ValuationRequestJob.perform_later(processed_data[:unique_id])
|
394
|
+
end
|
395
|
+
|
396
|
+
# Download images
|
397
|
+
if message.dig('data', 'image_urls').present?
|
398
|
+
ImageDownloadJob.perform_later(processed_data[:unique_id], message['data']['image_urls'])
|
399
|
+
end
|
400
|
+
|
401
|
+
when 'update'
|
402
|
+
# Check for significant changes
|
403
|
+
if processed_data[:estimated_hammer] != message.dig('previous_data', 'estimated_value')
|
404
|
+
AssetValueChangeNotificationJob.perform_later(processed_data[:unique_id])
|
405
|
+
end
|
406
|
+
|
407
|
+
when 'delete', 'destroy'
|
408
|
+
# Clean up related data
|
409
|
+
CleanupAssetDataJob.perform_later(processed_data[:unique_id])
|
410
|
+
end
|
411
|
+
|
412
|
+
# Update search index
|
413
|
+
SearchIndexUpdateJob.perform_later(processed_data[:unique_id], 'Asset')
|
563
414
|
end
|
564
415
|
end
|
565
416
|
```
|
566
417
|
|
567
|
-
|
568
|
-
|
569
|
-
### Automatic Publishing
|
570
|
-
|
571
|
-
Events are automatically published when you include `NatsPublishable`:
|
572
|
-
|
573
|
-
```ruby
|
574
|
-
# This automatically publishes to "valuation_service.events.assets.create"
|
575
|
-
asset = Asset.create!(
|
576
|
-
make: "Caterpillar",
|
577
|
-
model: "320D",
|
578
|
-
year: 2018,
|
579
|
-
serial: "ABC123456",
|
580
|
-
description: "Excavator - Track Type"
|
581
|
-
)
|
582
|
-
|
583
|
-
# This publishes to "valuation_service.events.assets.update"
|
584
|
-
asset.update!(estimated_hammer: 125000)
|
585
|
-
|
586
|
-
# This publishes to "valuation_service.events.assets.destroy"
|
587
|
-
asset.destroy!
|
588
|
-
```
|
589
|
-
|
590
|
-
### Manual Publishing
|
418
|
+
### Pure Custom Handlers (No Auto-Sync)
|
591
419
|
|
592
420
|
```ruby
|
593
|
-
|
594
|
-
NatsWave
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
)
|
610
|
-
|
611
|
-
|
612
|
-
events = assets.map do |asset|
|
613
|
-
{
|
614
|
-
subject: 'asset.valued',
|
615
|
-
model: 'Asset',
|
616
|
-
action: 'update',
|
617
|
-
data: asset.attributes.except('notes', 'edge_data')
|
618
|
-
}
|
421
|
+
class User < ApplicationRecord
|
422
|
+
include NatsWave::Concerns::Mappable
|
423
|
+
|
424
|
+
# Custom handler only - no auto-sync
|
425
|
+
nats_wave_subscribes_to "auth_service.user_sessions.*" do |model_name, action, processed_data, message|
|
426
|
+
# Pure custom processing - no database sync
|
427
|
+
case action
|
428
|
+
when 'login'
|
429
|
+
UserActivityTracker.track_login(message['data']['user_id'], message['data']['ip_address'])
|
430
|
+
Rails.cache.write("user_session:#{message['data']['user_id']}", message['data']['session_token'], expires_in: 1.hour)
|
431
|
+
|
432
|
+
when 'logout'
|
433
|
+
UserActivityTracker.track_logout(message['data']['user_id'])
|
434
|
+
Rails.cache.delete("user_session:#{message['data']['user_id']}")
|
435
|
+
|
436
|
+
when 'password_reset'
|
437
|
+
UserMailer.password_reset_notification(message['data']['user_id']).deliver_later
|
438
|
+
end
|
439
|
+
end
|
619
440
|
end
|
620
|
-
|
621
|
-
NatsWave.client.publish_batch(events)
|
622
|
-
```
|
623
|
-
|
624
|
-
## π₯ Subscribing to Events
|
625
|
-
|
626
|
-
### Automatic Model Syncing with Field Mapping
|
627
|
-
|
628
|
-
When you configure `nats_wave_maps_from`, NatsWave automatically:
|
629
|
-
|
630
|
-
1. **Registers the subscription** in the ModelRegistry
|
631
|
-
2. **Subscribes to specified subjects** when the application starts
|
632
|
-
3. **Transforms incoming data** using field mappings and transformations
|
633
|
-
4. **Applies conditions** to filter which messages to process
|
634
|
-
5. **Syncs data** to your local models using the specified strategy
|
635
|
-
|
636
|
-
```ruby
|
637
|
-
# Example: Incoming message from inventory system
|
638
|
-
{
|
639
|
-
"subject": "inventory_service.assets.update",
|
640
|
-
"model": "InventoryAsset",
|
641
|
-
"action": "update",
|
642
|
-
"data": {
|
643
|
-
"inventory_id": "INV-2024-001",
|
644
|
-
"asset_description": "2020 Ford F-150 Pickup Truck",
|
645
|
-
"manufacturer": "ford",
|
646
|
-
"model_number": "F-150",
|
647
|
-
"year_built": "2020",
|
648
|
-
"serial_num": "1FTFW1ET5LFA12345",
|
649
|
-
"vin_code": "1FTFW1ET5LFA12345",
|
650
|
-
"estimated_value": 35000.00,
|
651
|
-
"status": "available"
|
652
|
-
},
|
653
|
-
"source": {
|
654
|
-
"service": "inventory_service",
|
655
|
-
"instance_id": "inv-worker-1"
|
656
|
-
}
|
657
|
-
}
|
658
|
-
|
659
|
-
# NatsWave automatically transforms this to:
|
660
|
-
{
|
661
|
-
"unique_id": "INV-2024-001",
|
662
|
-
"description": "2020 Ford F-150 Pickup Truck",
|
663
|
-
"make": "FORD", # Uppercased via transformation
|
664
|
-
"model": "F-150",
|
665
|
-
"year": 2020, # Converted to integer
|
666
|
-
"serial": "1FTFW1ET5LFA12345",
|
667
|
-
"vin": "1FTFW1ET5LFA12345",
|
668
|
-
"estimated_hammer": 35000.00
|
669
|
-
}
|
670
|
-
|
671
|
-
# And creates/updates the local Asset record using unique_fields [:unique_id, :vin, :serial]
|
672
441
|
```
|
673
442
|
|
674
443
|
## πΊοΈ Data Mapping & Transformation
|
@@ -676,246 +445,255 @@ When you configure `nats_wave_maps_from`, NatsWave automatically:
|
|
676
445
|
### Complete Field Mapping Options
|
677
446
|
|
678
447
|
```ruby
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
},
|
705
|
-
|
706
|
-
# === SYNC STRATEGY ===
|
707
|
-
sync_strategy: :upsert, # Options: :upsert, :create_only, :update_only
|
708
|
-
|
709
|
-
# === UNIQUE IDENTIFICATION ===
|
710
|
-
unique_fields: [:external_id, :vin, :serial], # Fields to find existing records
|
711
|
-
|
712
|
-
# === SKIP FIELDS ===
|
713
|
-
skip_fields: [:internal_notes, :secret_key], # Fields to ignore
|
714
|
-
|
715
|
-
# === SUBSCRIPTION CONFIGURATION ===
|
716
|
-
subjects: [
|
717
|
-
'inventory_service.items.*',
|
718
|
-
'warehouse_service.assets.*',
|
719
|
-
'external_api.inventory.*'
|
720
|
-
]
|
448
|
+
nats_wave_subscribes_to "external_service.models.*",
|
449
|
+
# === FIELD MAPPINGS ===
|
450
|
+
field_mappings: {
|
451
|
+
'external_field' => 'local_field',
|
452
|
+
'their_id' => 'external_id',
|
453
|
+
'their_name' => 'title',
|
454
|
+
'created_timestamp' => 'created_at',
|
455
|
+
'nested.field.value' => 'flattened_value' # Flatten nested fields
|
456
|
+
},
|
457
|
+
|
458
|
+
# === DATA TRANSFORMATIONS ===
|
459
|
+
transformations: {
|
460
|
+
'created_at' => :parse_timestamp, # Method call
|
461
|
+
'make' => ->(value) { value&.upcase&.strip }, # Lambda
|
462
|
+
'year' => ->(year) { year.to_i if year.present? },
|
463
|
+
'price' => :convert_to_cents, # Custom method
|
464
|
+
'status' => ->(val, record) { "#{record['prefix']}_#{val}" }, # With record access
|
465
|
+
'tags' => ->(tags) { tags.is_a?(Array) ? tags.join(',') : tags } # Array handling
|
466
|
+
},
|
467
|
+
|
468
|
+
# === UNIQUE IDENTIFICATION ===
|
469
|
+
unique_fields: [:external_id, :vin, :serial], # Fields to find existing records
|
470
|
+
|
471
|
+
# === SKIP FIELDS ===
|
472
|
+
skip_fields: [:internal_notes, :secret_key] # Fields to ignore completely
|
721
473
|
```
|
722
474
|
|
723
475
|
### Advanced Transformations
|
724
476
|
|
725
477
|
```ruby
|
726
|
-
|
727
478
|
class Asset < ApplicationRecord
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
|
751
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
762
|
-
def self.normalize_serial(serial)
|
763
|
-
serial&.upcase&.strip&.gsub(/\s+/, '')
|
764
|
-
end
|
765
|
-
|
766
|
-
def self.parse_timestamp(timestamp)
|
767
|
-
return nil if timestamp.blank?
|
768
|
-
Time.parse(timestamp.to_s)
|
769
|
-
rescue ArgumentError
|
479
|
+
include NatsWave::Concerns::Mappable
|
480
|
+
|
481
|
+
nats_wave_subscribes_to "external_assets.*",
|
482
|
+
transformations: {
|
483
|
+
# Simple lambda transformations
|
484
|
+
'estimated_hammer' => ->(value) { value.to_f.round(2) },
|
485
|
+
'make' => ->(make) { make&.upcase&.strip },
|
486
|
+
|
487
|
+
# Method transformations
|
488
|
+
'vin' => :normalize_vin,
|
489
|
+
'serial' => :normalize_serial,
|
490
|
+
|
491
|
+
# Complex transformations with full record access
|
492
|
+
'description' => ->(value, record) {
|
493
|
+
parts = [record['year'], record['make'], record['model'], value].compact
|
494
|
+
parts.join(' ').strip
|
495
|
+
},
|
496
|
+
|
497
|
+
# Conditional transformations
|
498
|
+
'condition' => ->(condition) {
|
499
|
+
case condition&.downcase
|
500
|
+
when 'excellent', 'like new' then 'excellent'
|
501
|
+
when 'good', 'fair' then 'good'
|
502
|
+
when 'poor', 'needs repair' then 'poor'
|
503
|
+
else 'unknown'
|
504
|
+
end
|
505
|
+
},
|
506
|
+
|
507
|
+
# Date/time parsing
|
508
|
+
'manufactured_date' => ->(date_str) {
|
509
|
+
return nil if date_str.blank?
|
510
|
+
Date.parse(date_str.to_s)
|
511
|
+
rescue ArgumentError
|
770
512
|
nil
|
513
|
+
}
|
514
|
+
} do |model_name, action, processed_data, message|
|
515
|
+
Rails.logger.info "π Transformed and synced #{model_name}: #{processed_data[:unique_id]}"
|
771
516
|
end
|
772
|
-
|
773
|
-
|
774
|
-
|
775
|
-
|
517
|
+
|
518
|
+
private
|
519
|
+
|
520
|
+
def self.normalize_vin(vin)
|
521
|
+
vin&.upcase&.gsub(/[^A-Z0-9]/, '')&.strip
|
522
|
+
end
|
523
|
+
|
524
|
+
def self.normalize_serial(serial)
|
525
|
+
serial&.upcase&.strip&.gsub(/\s+/, '')
|
526
|
+
end
|
527
|
+
|
528
|
+
def self.parse_timestamp(timestamp)
|
529
|
+
return nil if timestamp.blank?
|
530
|
+
Time.parse(timestamp.to_s)
|
531
|
+
rescue ArgumentError
|
532
|
+
nil
|
533
|
+
end
|
534
|
+
|
535
|
+
def self.convert_to_cents(dollars)
|
536
|
+
(dollars.to_f * 100).round
|
537
|
+
end
|
776
538
|
end
|
777
539
|
```
|
778
540
|
|
779
541
|
## π Cross-Service Integration
|
780
542
|
|
781
|
-
###
|
543
|
+
### Multiple Services with Same Model Names
|
782
544
|
|
783
545
|
```ruby
|
784
|
-
|
785
546
|
class Package < ApplicationRecord
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
|
805
|
-
|
806
|
-
|
807
|
-
|
808
|
-
|
809
|
-
|
810
|
-
|
811
|
-
|
812
|
-
|
813
|
-
|
814
|
-
case service_name
|
815
|
-
when 'inventory_service'
|
816
|
-
InventoryPackageProcessor.process(message)
|
817
|
-
when 'order_service'
|
818
|
-
OrderPackageProcessor.process(message)
|
819
|
-
when 'fulfillment_service'
|
820
|
-
FulfillmentPackageProcessor.process(message)
|
821
|
-
else
|
822
|
-
Rails.logger.warn "Unknown service: #{service_name}"
|
823
|
-
end
|
547
|
+
include NatsWave::Concerns::Mappable
|
548
|
+
|
549
|
+
# Handle packages from different services
|
550
|
+
nats_wave_subscribes_to "inventory_service.packages.*",
|
551
|
+
field_mappings: {
|
552
|
+
'sku' => 'product_code',
|
553
|
+
'inventory_status' => 'status'
|
554
|
+
},
|
555
|
+
unique_fields: [:product_code] do |model_name, action, processed_data, message|
|
556
|
+
source_service = message.dig('source', 'service')
|
557
|
+
Rails.logger.info "π¦ Inventory package #{action}: #{processed_data[:product_code]} from #{source_service}"
|
558
|
+
|
559
|
+
# Inventory-specific processing
|
560
|
+
InventoryPackageProcessor.process(processed_data, action)
|
561
|
+
end
|
562
|
+
|
563
|
+
nats_wave_subscribes_to "order_service.packages.*",
|
564
|
+
field_mappings: {
|
565
|
+
'order_item_id' => 'external_order_id',
|
566
|
+
'item_name' => 'name'
|
567
|
+
},
|
568
|
+
unique_fields: [:external_order_id] do |model_name, action, processed_data, message|
|
569
|
+
source_service = message.dig('source', 'service')
|
570
|
+
Rails.logger.info "π¦ Order package #{action}: #{processed_data[:external_order_id]} from #{source_service}"
|
571
|
+
|
572
|
+
# Order-specific processing
|
573
|
+
OrderPackageProcessor.process(processed_data, action)
|
824
574
|
end
|
825
575
|
end
|
826
576
|
```
|
827
577
|
|
828
|
-
###
|
578
|
+
### Different External Model Names
|
829
579
|
|
830
580
|
```ruby
|
831
|
-
|
832
581
|
class Asset < ApplicationRecord
|
833
|
-
|
834
|
-
|
835
|
-
|
836
|
-
|
837
|
-
|
838
|
-
|
839
|
-
|
840
|
-
|
841
|
-
|
842
|
-
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
|
847
|
-
|
848
|
-
|
849
|
-
|
582
|
+
include NatsWave::Concerns::Mappable
|
583
|
+
|
584
|
+
# Map different external model names to local Asset model
|
585
|
+
nats_wave_subscribes_to "inventory_service.inventory_items.*",
|
586
|
+
field_mappings: {
|
587
|
+
'sku' => 'product_code',
|
588
|
+
'stock_level' => 'quantity'
|
589
|
+
},
|
590
|
+
unique_fields: [:product_code] do |model_name, action, processed_data, message|
|
591
|
+
Rails.logger.info "π¦ Inventory item -> Asset: #{processed_data[:product_code]}"
|
592
|
+
end
|
593
|
+
|
594
|
+
nats_wave_subscribes_to "order_service.order_items.*",
|
595
|
+
field_mappings: {
|
596
|
+
'item_id' => 'external_order_id',
|
597
|
+
'item_name' => 'name'
|
598
|
+
},
|
599
|
+
unique_fields: [:external_order_id] do |model_name, action, processed_data, message|
|
600
|
+
Rails.logger.info "π Order item -> Asset: #{processed_data[:external_order_id]}"
|
601
|
+
end
|
602
|
+
|
603
|
+
nats_wave_subscribes_to "fulfillment_service.shipment_items.*",
|
604
|
+
field_mappings: {
|
605
|
+
'tracking_code' => 'tracking_number'
|
606
|
+
},
|
607
|
+
unique_fields: [:tracking_number] do |model_name, action, processed_data, message|
|
608
|
+
Rails.logger.info "π Shipment item -> Asset: #{processed_data[:tracking_number]}"
|
609
|
+
end
|
850
610
|
end
|
851
611
|
```
|
852
612
|
|
853
613
|
## π― Message Processing & Handlers
|
854
614
|
|
855
|
-
###
|
615
|
+
### Auto-Sync Process Flow
|
616
|
+
|
617
|
+
When a message is received, NatsWave automatically:
|
856
618
|
|
857
|
-
|
619
|
+
1. **Parses the message** and extracts data
|
620
|
+
2. **Applies field mappings** to transform field names
|
621
|
+
3. **Runs transformations** on the mapped data
|
622
|
+
4. **Finds existing records** using unique_fields
|
623
|
+
5. **Performs database operation** (create/update/delete)
|
624
|
+
6. **Calls custom handler** with the processed data (if provided)
|
858
625
|
|
859
626
|
```ruby
|
860
|
-
#
|
861
|
-
|
862
|
-
|
863
|
-
|
864
|
-
|
865
|
-
|
866
|
-
|
867
|
-
|
868
|
-
|
869
|
-
|
870
|
-
|
871
|
-
|
872
|
-
|
873
|
-
|
874
|
-
|
875
|
-
|
876
|
-
|
877
|
-
|
878
|
-
model_class = local_model_name.constantize
|
879
|
-
|
880
|
-
case message['action']
|
881
|
-
when 'create', 'created'
|
882
|
-
sync_create(model_class, transformed_data, mapping_config)
|
883
|
-
when 'update', 'updated'
|
884
|
-
sync_update(model_class, transformed_data, mapping_config)
|
885
|
-
when 'delete', 'deleted', 'destroy'
|
886
|
-
sync_delete(model_class, transformed_data, mapping_config)
|
887
|
-
end
|
888
|
-
end
|
889
|
-
end
|
890
|
-
end
|
891
|
-
end
|
627
|
+
# Example flow for incoming message:
|
628
|
+
{
|
629
|
+
"subject": "inventory_service.items.update",
|
630
|
+
"model": "InventoryItem",
|
631
|
+
"action": "update",
|
632
|
+
"data": {
|
633
|
+
"item_id": "INV-2024-001",
|
634
|
+
"manufacturer": "ford",
|
635
|
+
"estimated_value": 35000.00
|
636
|
+
}
|
637
|
+
}
|
638
|
+
|
639
|
+
# NatsWave automatically:
|
640
|
+
# 1. Maps 'item_id' -> 'unique_id', 'manufacturer' -> 'make'
|
641
|
+
# 2. Transforms 'make' to 'FORD' (uppercase)
|
642
|
+
# 3. Finds Asset.find_by(unique_id: 'INV-2024-001')
|
643
|
+
# 4. Updates the asset with transformed data
|
644
|
+
# 5. Calls your custom handler with processed data
|
892
645
|
```
|
893
646
|
|
894
|
-
### Custom
|
647
|
+
### Custom Handler Parameters
|
895
648
|
|
896
649
|
```ruby
|
897
|
-
|
898
|
-
|
899
|
-
|
900
|
-
|
901
|
-
|
902
|
-
|
903
|
-
|
904
|
-
|
905
|
-
|
906
|
-
|
907
|
-
|
908
|
-
|
650
|
+
nats_wave_subscribes_to "external_service.items.*" do |model_name, action, processed_data, original_message|
|
651
|
+
# model_name: The local model name ('Asset')
|
652
|
+
# action: The action performed ('create', 'update', 'delete')
|
653
|
+
# processed_data: Hash of the final processed/transformed data
|
654
|
+
# original_message: Full original NATS message hash
|
655
|
+
|
656
|
+
Rails.logger.info "Handler called for #{model_name}##{action}"
|
657
|
+
Rails.logger.info "Processed data keys: #{processed_data.keys}"
|
658
|
+
Rails.logger.info "Original message source: #{original_message.dig('source', 'service')}"
|
659
|
+
|
660
|
+
# Your custom logic here
|
661
|
+
case action
|
662
|
+
when 'create'
|
663
|
+
WelcomeEmailJob.perform_later(processed_data[:id])
|
664
|
+
when 'update'
|
665
|
+
CacheInvalidationJob.perform_later(processed_data[:id])
|
666
|
+
when 'delete'
|
667
|
+
CleanupJob.perform_later(processed_data[:id])
|
668
|
+
end
|
909
669
|
end
|
670
|
+
```
|
910
671
|
|
911
|
-
|
912
|
-
|
913
|
-
|
914
|
-
|
915
|
-
|
916
|
-
|
672
|
+
### Error Handling in Handlers
|
673
|
+
|
674
|
+
```ruby
|
675
|
+
nats_wave_subscribes_to "risky_service.items.*",
|
676
|
+
unique_fields: [:external_id] do |model_name, action, processed_data, message|
|
677
|
+
begin
|
678
|
+
# Your processing logic
|
679
|
+
RiskyExternalService.process(processed_data)
|
680
|
+
|
681
|
+
Rails.logger.info "β
Successfully processed #{model_name} #{action}"
|
682
|
+
|
683
|
+
rescue RiskyExternalService::RateLimitError => e
|
684
|
+
# Retry later for rate limits
|
685
|
+
Rails.logger.warn "β±οΈ Rate limited, retrying later: #{e.message}"
|
686
|
+
RetryProcessingJob.set(wait: 5.minutes).perform_later(processed_data, message)
|
687
|
+
|
688
|
+
rescue => e
|
689
|
+
# Log error but don't re-raise (would kill subscription)
|
690
|
+
Rails.logger.error "β Handler error for #{model_name} #{action}: #{e.message}"
|
691
|
+
ErrorTracker.notify(e, context: { model: model_name, action: action, data: processed_data })
|
692
|
+
|
693
|
+
# Send to dead letter queue for manual review
|
694
|
+
DeadLetterQueue.store(message, e)
|
917
695
|
end
|
918
|
-
end
|
696
|
+
end
|
919
697
|
```
|
920
698
|
|
921
699
|
## π Monitoring & Metrics
|
@@ -923,28 +701,46 @@ end
|
|
923
701
|
### ModelRegistry Inspection
|
924
702
|
|
925
703
|
```ruby
|
926
|
-
# View all registered subscriptions
|
927
|
-
|
704
|
+
# View all registered subscriptions and mappings
|
705
|
+
rails console
|
928
706
|
|
929
|
-
#
|
930
|
-
|
707
|
+
# Debug all registrations
|
708
|
+
> NatsWave::ModelRegistry.debug_registrations
|
709
|
+
|
710
|
+
# Get subscription statistics
|
711
|
+
> stats = NatsWave::ModelRegistry.subscription_stats
|
931
712
|
# => {
|
932
713
|
# total_subscriptions: 5,
|
933
|
-
# unique_subjects: 8,
|
714
|
+
# unique_subjects: 8,
|
934
715
|
# models_with_subscriptions: 3,
|
935
716
|
# subscription_breakdown: {"Asset"=>2, "Package"=>2, "User"=>1}
|
936
717
|
# }
|
937
718
|
|
938
|
-
# View
|
939
|
-
|
719
|
+
# View subscriptions for specific model
|
720
|
+
> NatsWave::ModelRegistry.subscriptions_for_model('Asset')
|
721
|
+
|
722
|
+
# Check if external model can be synced
|
723
|
+
> NatsWave::ModelRegistry.can_sync_external_model?('InventoryItem')
|
724
|
+
# => true
|
725
|
+
```
|
726
|
+
|
727
|
+
### Built-in Health Checks
|
728
|
+
|
729
|
+
```ruby
|
730
|
+
# Check overall system health
|
731
|
+
> NatsWave.health_check
|
940
732
|
# => {
|
941
|
-
#
|
942
|
-
#
|
733
|
+
# "nats_connected": true,
|
734
|
+
# "database_connected": true,
|
735
|
+
# "timestamp": "2024-01-21T12:00:00Z",
|
736
|
+
# "service_name": "valuation_service",
|
737
|
+
# "version": "1.2.0",
|
738
|
+
# "instance_id": "web-1",
|
739
|
+
# "model_registry_stats": {
|
740
|
+
# "total_subscriptions": 5,
|
741
|
+
# "unique_subjects": 8
|
742
|
+
# }
|
943
743
|
# }
|
944
|
-
|
945
|
-
# Find local models for external model
|
946
|
-
local_models = NatsWave::ModelRegistry.local_models_for('InventoryItem')
|
947
|
-
# => {"Asset" => {field_mappings: {...}, transformations: {...}}}
|
948
744
|
```
|
949
745
|
|
950
746
|
### Datadog Integration
|
@@ -953,93 +749,117 @@ NatsWave provides comprehensive Datadog metrics:
|
|
953
749
|
|
954
750
|
```ruby
|
955
751
|
# Automatic metrics tracked:
|
956
|
-
-nats_wave.messages.published
|
957
|
-
-nats_wave.messages.received
|
958
|
-
-nats_wave.processing_time
|
959
|
-
-nats_wave.
|
960
|
-
-nats_wave.
|
961
|
-
-nats_wave.
|
962
|
-
-nats_wave.
|
963
|
-
-nats_wave.
|
752
|
+
# - nats_wave.messages.published (by subject/model/action)
|
753
|
+
# - nats_wave.messages.received (by subject/model/action)
|
754
|
+
# - nats_wave.processing_time (message processing duration)
|
755
|
+
# - nats_wave.sync_operations (auto-sync operations by model/action)
|
756
|
+
# - nats_wave.handler_execution_time (custom handler duration)
|
757
|
+
# - nats_wave.errors (errors by type and subject)
|
758
|
+
# - nats_wave.connection_status (NATS connection health)
|
759
|
+
# - nats_wave.retries (retry attempts)
|
760
|
+
|
761
|
+
# Enable in config/initializers/nats_wave.rb
|
762
|
+
if defined?(Datadog::Statsd) && !Rails.env.test?
|
763
|
+
NatsWave::Metrics.configure_datadog(
|
764
|
+
namespace: "#{Rails.application.class.name.deconstantize.underscore}.nats_wave",
|
765
|
+
tags: {
|
766
|
+
service: NatsWave.configuration.service_name,
|
767
|
+
environment: Rails.env,
|
768
|
+
version: Rails.application.config.version
|
769
|
+
}
|
770
|
+
)
|
771
|
+
end
|
964
772
|
```
|
965
773
|
|
966
|
-
### Health
|
774
|
+
### Health Check Endpoint
|
967
775
|
|
968
776
|
```ruby
|
969
|
-
#
|
970
|
-
|
777
|
+
# config/routes.rb
|
778
|
+
Rails.application.routes.draw do
|
779
|
+
get '/health/nats_wave', to: 'health#nats_wave'
|
780
|
+
end
|
971
781
|
|
972
|
-
#
|
973
|
-
|
974
|
-
|
975
|
-
|
976
|
-
|
977
|
-
|
978
|
-
|
979
|
-
|
980
|
-
|
981
|
-
|
982
|
-
|
983
|
-
|
984
|
-
|
985
|
-
|
986
|
-
|
987
|
-
|
988
|
-
|
989
|
-
|
990
|
-
|
991
|
-
}
|
992
|
-
}
|
782
|
+
# app/controllers/health_controller.rb
|
783
|
+
class HealthController < ApplicationController
|
784
|
+
def nats_wave
|
785
|
+
health_data = NatsWave.health_check
|
786
|
+
|
787
|
+
if health_data[:nats_connected]
|
788
|
+
render json: health_data, status: :ok
|
789
|
+
else
|
790
|
+
render json: health_data.merge(error: 'NATS not connected'),
|
791
|
+
status: :service_unavailable
|
792
|
+
end
|
793
|
+
rescue => e
|
794
|
+
render json: {
|
795
|
+
error: e.message,
|
796
|
+
nats_connected: false,
|
797
|
+
timestamp: Time.current.iso8601
|
798
|
+
}, status: :service_unavailable
|
799
|
+
end
|
800
|
+
end
|
993
801
|
```
|
994
802
|
|
995
803
|
## π‘οΈ Error Handling
|
996
804
|
|
997
|
-
###
|
805
|
+
### Automatic Error Recovery
|
998
806
|
|
999
|
-
|
807
|
+
NatsWave includes robust automatic error handling:
|
1000
808
|
|
1001
|
-
|
1002
|
-
|
1003
|
-
|
1004
|
-
|
1005
|
-
# Retry 3: after 125 seconds
|
1006
|
-
# After max retries: moved to permanent failure storage
|
1007
|
-
```
|
809
|
+
- **Auto-Reconnection**: Automatic reconnection with exponential backoff
|
810
|
+
- **Health Monitoring**: Continuous connection health checks
|
811
|
+
- **Dead Letter Queue**: Failed messages stored for manual review
|
812
|
+
- **Retry Logic**: Configurable retry attempts with delays
|
1008
813
|
|
1009
|
-
###
|
1010
|
-
|
1011
|
-
```bash
|
1012
|
-
# View failed messages
|
1013
|
-
rails nats_wave:show_failed
|
814
|
+
### Connection Recovery
|
1014
815
|
|
1015
|
-
|
1016
|
-
|
1017
|
-
|
1018
|
-
#
|
1019
|
-
|
1020
|
-
|
1021
|
-
|
1022
|
-
|
816
|
+
```ruby
|
817
|
+
# The client automatically handles:
|
818
|
+
# - Network disconnections
|
819
|
+
# - NATS server restarts
|
820
|
+
# - Temporary connection issues
|
821
|
+
# - Subscription restoration after reconnection
|
822
|
+
|
823
|
+
# Monitor connection health in logs:
|
824
|
+
# π Subscriber connection healthy - 3 active subscriptions
|
825
|
+
# π NATS disconnected: Connection lost
|
826
|
+
# π Attempting to reconnect to NATS... (attempt #1)
|
827
|
+
# β
NATS client reconnected successfully
|
828
|
+
# β
Subscriptions restored (3 active)
|
1023
829
|
```
|
1024
830
|
|
1025
831
|
### Custom Error Handling
|
1026
832
|
|
1027
833
|
```ruby
|
1028
|
-
|
1029
834
|
class CustomErrorHandler
|
1030
|
-
|
1031
|
-
|
1032
|
-
|
1033
|
-
|
1034
|
-
|
1035
|
-
|
1036
|
-
|
1037
|
-
|
1038
|
-
|
1039
|
-
|
1040
|
-
|
1041
|
-
|
835
|
+
def self.handle_subscription_error(error, message, model_name)
|
836
|
+
# Custom error processing
|
837
|
+
case error
|
838
|
+
when ValidationError
|
839
|
+
Rails.logger.warn "β οΈ Validation failed for #{model_name}: #{error.message}"
|
840
|
+
# Maybe send to a separate queue for data cleanup
|
841
|
+
|
842
|
+
when ExternalServiceError
|
843
|
+
Rails.logger.error "π External service error: #{error.message}"
|
844
|
+
# Retry with exponential backoff
|
845
|
+
RetryJob.set(wait: 2.minutes).perform_later(message)
|
846
|
+
|
847
|
+
else
|
848
|
+
Rails.logger.error "β Unexpected error processing #{model_name}: #{error.message}"
|
849
|
+
# Send to monitoring
|
850
|
+
ErrorTracker.notify(error, context: { model: model_name, message: message })
|
1042
851
|
end
|
852
|
+
end
|
853
|
+
end
|
854
|
+
|
855
|
+
# Use in your model handlers
|
856
|
+
nats_wave_subscribes_to "external.items.*" do |model_name, action, data, message|
|
857
|
+
begin
|
858
|
+
# Your processing logic
|
859
|
+
ExternalService.process(data)
|
860
|
+
rescue => e
|
861
|
+
CustomErrorHandler.handle_subscription_error(e, message, model_name)
|
862
|
+
end
|
1043
863
|
end
|
1044
864
|
```
|
1045
865
|
|
@@ -1050,72 +870,160 @@ end
|
|
1050
870
|
```ruby
|
1051
871
|
# spec/spec_helper.rb or test/test_helper.rb
|
1052
872
|
RSpec.configure do |config|
|
1053
|
-
|
1054
|
-
|
1055
|
-
|
1056
|
-
|
1057
|
-
|
1058
|
-
|
1059
|
-
|
1060
|
-
|
1061
|
-
|
873
|
+
config.before(:suite) do
|
874
|
+
# Clear ModelRegistry before tests
|
875
|
+
NatsWave::ModelRegistry.clear! if defined?(NatsWave::ModelRegistry)
|
876
|
+
end
|
877
|
+
|
878
|
+
config.before(:each) do
|
879
|
+
# Mock NATS operations in tests by default
|
880
|
+
if defined?(NatsWave)
|
881
|
+
allow(NatsWave).to receive(:publish)
|
882
|
+
allow(NatsWave.client).to receive(:publish) if NatsWave.client
|
883
|
+
allow(NatsWave.client).to receive(:subscribe) if NatsWave.client
|
1062
884
|
end
|
885
|
+
end
|
1063
886
|
end
|
1064
887
|
```
|
1065
888
|
|
1066
|
-
### Testing Model
|
889
|
+
### Testing Model Configuration
|
1067
890
|
|
1068
891
|
```ruby
|
1069
892
|
# spec/models/asset_spec.rb
|
1070
893
|
RSpec.describe Asset, type: :model do
|
1071
|
-
|
1072
|
-
|
1073
|
-
|
1074
|
-
|
1075
|
-
|
1076
|
-
|
1077
|
-
|
1078
|
-
|
1079
|
-
|
1080
|
-
|
1081
|
-
|
1082
|
-
|
1083
|
-
|
1084
|
-
|
1085
|
-
|
894
|
+
describe 'NATS Wave configuration' do
|
895
|
+
it 'registers correct subscriptions' do
|
896
|
+
subscriptions = NatsWave::ModelRegistry.subscriptions_for_model('Asset')
|
897
|
+
expect(subscriptions.size).to eq(1)
|
898
|
+
|
899
|
+
subjects = subscriptions.flat_map { |s| s[:subjects] }
|
900
|
+
expect(subjects).to include('inventory_service.items.*')
|
901
|
+
end
|
902
|
+
|
903
|
+
it 'has correct field mappings for inventory items' do
|
904
|
+
# Test that the model is registered to handle InventoryItem
|
905
|
+
local_models = NatsWave::ModelRegistry.local_models_for('InventoryItem')
|
906
|
+
expect(local_models).to have_key('Asset')
|
907
|
+
|
908
|
+
mapping = local_models['Asset']
|
909
|
+
expect(mapping[:field_mappings]['item_id']).to eq('unique_id')
|
910
|
+
expect(mapping[:field_mappings]['manufacturer']).to eq('make')
|
911
|
+
end
|
912
|
+
|
913
|
+
it 'has correct transformations' do
|
914
|
+
local_models = NatsWave::ModelRegistry.local_models_for('InventoryItem')
|
915
|
+
mapping = local_models['Asset']
|
916
|
+
|
917
|
+
# Test transformation functions
|
918
|
+
make_transformer = mapping[:transformations]['make']
|
919
|
+
expect(make_transformer.call('ford')).to eq('FORD')
|
920
|
+
expect(make_transformer.call(' Caterpillar ')).to eq('CATERPILLAR')
|
1086
921
|
end
|
922
|
+
end
|
1087
923
|
end
|
1088
924
|
```
|
1089
925
|
|
1090
926
|
### Testing Message Processing
|
1091
927
|
|
1092
928
|
```ruby
|
1093
|
-
# spec/services/
|
1094
|
-
RSpec.describe
|
1095
|
-
|
1096
|
-
|
1097
|
-
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
1101
|
-
|
1102
|
-
|
1103
|
-
|
1104
|
-
|
1105
|
-
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
1110
|
-
|
1111
|
-
|
1112
|
-
|
1113
|
-
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
1117
|
-
|
929
|
+
# spec/services/nats_wave_integration_spec.rb
|
930
|
+
RSpec.describe 'NATS Wave Integration' do
|
931
|
+
let(:sample_message) do
|
932
|
+
{
|
933
|
+
'model' => 'InventoryItem',
|
934
|
+
'action' => 'create',
|
935
|
+
'data' => {
|
936
|
+
'item_id' => 'INV-001',
|
937
|
+
'manufacturer' => 'caterpillar',
|
938
|
+
'model_name' => '320D',
|
939
|
+
'year_manufactured' => '2020',
|
940
|
+
'estimated_value' => 125000.00
|
941
|
+
},
|
942
|
+
'source' => { 'service' => 'inventory_service' }
|
943
|
+
}
|
944
|
+
end
|
945
|
+
|
946
|
+
describe 'message processing' do
|
947
|
+
it 'creates asset from inventory message' do
|
948
|
+
expect {
|
949
|
+
# Simulate message processing (this would normally happen automatically)
|
950
|
+
NatsWave::MessageHandler.process(sample_message)
|
951
|
+
}.to change(Asset, :count).by(1)
|
952
|
+
|
953
|
+
asset = Asset.last
|
954
|
+
expect(asset.unique_id).to eq('INV-001')
|
955
|
+
expect(asset.make).to eq('CATERPILLAR') # Uppercased by transformation
|
956
|
+
expect(asset.model).to eq('320D')
|
957
|
+
expect(asset.year).to eq(2020) # Converted to integer
|
958
|
+
expect(asset.estimated_hammer).to eq(12500000) # Converted to cents
|
1118
959
|
end
|
960
|
+
|
961
|
+
it 'calls custom handler after auto-sync' do
|
962
|
+
expect(ValuationRequestJob).to receive(:perform_later).with('INV-001')
|
963
|
+
|
964
|
+
# This would trigger both auto-sync and custom handler
|
965
|
+
NatsWave::MessageHandler.process(sample_message)
|
966
|
+
end
|
967
|
+
end
|
968
|
+
|
969
|
+
describe 'publishing' do
|
970
|
+
it 'publishes asset updates with field mappings' do
|
971
|
+
asset = Asset.create!(
|
972
|
+
unique_id: 'TEST-001',
|
973
|
+
make: 'FORD',
|
974
|
+
model: 'F-150',
|
975
|
+
year: 2020
|
976
|
+
)
|
977
|
+
|
978
|
+
expect(NatsWave).to receive(:publish) do |args|
|
979
|
+
expect(args[:subject]).to match(/assets\.update/)
|
980
|
+
expect(args[:data]['asset_id']).to eq('TEST-001') # Mapped from unique_id
|
981
|
+
expect(args[:data]['manufacturer']).to eq('FORD') # Mapped from make
|
982
|
+
end
|
983
|
+
|
984
|
+
asset.update!(make: 'FORD-UPDATED')
|
985
|
+
end
|
986
|
+
end
|
987
|
+
end
|
988
|
+
```
|
989
|
+
|
990
|
+
### Testing Custom Handlers
|
991
|
+
|
992
|
+
```ruby
|
993
|
+
# spec/models/asset_nats_handlers_spec.rb
|
994
|
+
RSpec.describe 'Asset NATS Handlers' do
|
995
|
+
describe 'subscription handler' do
|
996
|
+
let(:message) do
|
997
|
+
{
|
998
|
+
'model' => 'InventoryItem',
|
999
|
+
'action' => 'create',
|
1000
|
+
'data' => { 'item_id' => 'TEST-001', 'estimated_value' => nil }
|
1001
|
+
}
|
1002
|
+
end
|
1003
|
+
|
1004
|
+
it 'queues valuation request for assets without estimates' do
|
1005
|
+
expect(ValuationRequestJob).to receive(:perform_later).with('TEST-001')
|
1006
|
+
|
1007
|
+
# Simulate the handler being called
|
1008
|
+
# In real usage, this happens automatically when messages are received
|
1009
|
+
Asset.new.instance_eval do
|
1010
|
+
# Call the handler block manually for testing
|
1011
|
+
processed_data = { unique_id: 'TEST-001', estimated_hammer: nil }
|
1012
|
+
# Your handler logic here...
|
1013
|
+
end
|
1014
|
+
end
|
1015
|
+
end
|
1016
|
+
|
1017
|
+
describe 'publishing handler' do
|
1018
|
+
it 'sends notifications after publishing valuation updates' do
|
1019
|
+
asset = Asset.create!(unique_id: 'TEST-001', workflow_status: 'pending')
|
1020
|
+
|
1021
|
+
expect(NotificationService).to receive(:notify_valuation_complete)
|
1022
|
+
|
1023
|
+
# This would trigger publishing + custom handler
|
1024
|
+
asset.update!(workflow_status: 'complete')
|
1025
|
+
end
|
1026
|
+
end
|
1119
1027
|
end
|
1120
1028
|
```
|
1121
1029
|
|
@@ -1177,71 +1085,58 @@ spec:
|
|
1177
1085
|
app: valuation-service
|
1178
1086
|
spec:
|
1179
1087
|
containers:
|
1180
|
-
|
1181
|
-
|
1182
|
-
|
1183
|
-
|
1184
|
-
|
1185
|
-
|
1186
|
-
|
1187
|
-
|
1188
|
-
|
1189
|
-
|
1190
|
-
|
1191
|
-
|
1192
|
-
|
1193
|
-
|
1194
|
-
|
1195
|
-
|
1196
|
-
|
1197
|
-
|
1198
|
-
|
1199
|
-
|
1200
|
-
|
1088
|
+
- name: app
|
1089
|
+
image: valuation-service:latest
|
1090
|
+
env:
|
1091
|
+
- name: NATS_URL
|
1092
|
+
value: "nats://nats-cluster:4222"
|
1093
|
+
- name: NATS_SERVICE_NAME
|
1094
|
+
value: "valuation-service"
|
1095
|
+
- name: NATS_AUTH_SECRET
|
1096
|
+
valueFrom:
|
1097
|
+
secretKeyRef:
|
1098
|
+
name: nats-wave-secrets
|
1099
|
+
key: auth-secret
|
1100
|
+
ports:
|
1101
|
+
- containerPort: 3000
|
1102
|
+
livenessProbe:
|
1103
|
+
httpGet:
|
1104
|
+
path: /health/nats_wave
|
1105
|
+
port: 3000
|
1106
|
+
initialDelaySeconds: 30
|
1107
|
+
periodSeconds: 10
|
1108
|
+
readinessProbe:
|
1109
|
+
httpGet:
|
1110
|
+
path: /health/nats_wave
|
1111
|
+
port: 3000
|
1112
|
+
initialDelaySeconds: 5
|
1113
|
+
periodSeconds: 5
|
1201
1114
|
```
|
1202
1115
|
|
1203
1116
|
## π‘ Examples
|
1204
1117
|
|
1205
|
-
### Complete Asset
|
1118
|
+
### Complete Asset Workflow Integration
|
1206
1119
|
|
1207
1120
|
```ruby
|
1208
1121
|
# app/models/asset.rb
|
1209
1122
|
class Asset < ApplicationRecord
|
1210
|
-
include NatsWave::NatsPublishable
|
1211
1123
|
include NatsWave::Concerns::Mappable
|
1212
1124
|
|
1213
1125
|
belongs_to :package
|
1214
|
-
belongs_to :creator, class_name: 'User', optional: true
|
1215
|
-
belongs_to :last_updater, class_name: 'User', optional: true
|
1216
|
-
belongs_to :subpackage, optional: true
|
1217
1126
|
has_many :estimates, dependent: :destroy
|
1218
1127
|
has_many :images, dependent: :destroy
|
1219
|
-
has_many :videos, dependent: :destroy
|
1220
|
-
has_many :links, dependent: :destroy
|
1221
|
-
has_many :attachments, dependent: :destroy
|
1222
|
-
|
1223
|
-
# Publish asset changes to other services
|
1224
|
-
nats_publishable(
|
1225
|
-
actions: [:create, :update],
|
1226
|
-
skip_attributes: [:notes, :edge_data, :source_data, :ims_forms_data],
|
1227
|
-
include_associations: [:package, :estimates],
|
1228
|
-
subject_prefix: 'assets',
|
1229
|
-
if: -> { asset_status != 'draft' }
|
1230
|
-
)
|
1231
1128
|
|
1232
|
-
#
|
1233
|
-
|
1129
|
+
# Subscribe to inventory system - auto-sync with custom processing
|
1130
|
+
nats_wave_subscribes_to "inventory_service.assets.*", "warehouse_service.assets.*",
|
1234
1131
|
field_mappings: {
|
1235
1132
|
'inventory_id' => 'unique_id',
|
1236
1133
|
'asset_description' => 'description',
|
1237
1134
|
'manufacturer' => 'make',
|
1238
|
-
'model_number' => 'model',
|
1135
|
+
'model_number' => 'model',
|
1239
1136
|
'year_manufactured' => 'year',
|
1240
1137
|
'serial_number' => 'serial',
|
1241
1138
|
'vin_number' => 'vin',
|
1242
1139
|
'location_name' => 'location',
|
1243
|
-
'inventory_tag' => 'inventory_tag',
|
1244
|
-
'sticker_number' => 'sticker',
|
1245
1140
|
'estimated_value' => 'estimated_hammer'
|
1246
1141
|
},
|
1247
1142
|
transformations: {
|
@@ -1249,19 +1144,43 @@ class Asset < ApplicationRecord
|
|
1249
1144
|
'model' => ->(model) { model&.titleize&.strip },
|
1250
1145
|
'year' => ->(year) { year.to_i if year.present? },
|
1251
1146
|
'estimated_hammer' => ->(value) { (value.to_f * 100).round }, # Convert to cents
|
1252
|
-
'asset_status' => ->(status) { status&.downcase || '
|
1147
|
+
'asset_status' => ->(status) { status&.downcase || 'active' }
|
1253
1148
|
},
|
1254
1149
|
unique_fields: [:unique_id, :vin, :serial],
|
1255
|
-
|
1256
|
-
|
1257
|
-
|
1258
|
-
|
1259
|
-
'
|
1260
|
-
|
1261
|
-
|
1262
|
-
|
1263
|
-
|
1264
|
-
|
1150
|
+
skip_fields: ['internal_inventory_code', 'warehouse_notes'] do |model_name, action, processed_data, message|
|
1151
|
+
Rails.logger.info "π¦ Inventory sync: #{action} for #{processed_data[:unique_id]}"
|
1152
|
+
|
1153
|
+
case action
|
1154
|
+
when 'create'
|
1155
|
+
# Auto-request valuation for new assets without estimates
|
1156
|
+
if processed_data[:estimated_hammer].blank?
|
1157
|
+
ValuationRequestJob.perform_later(processed_data[:unique_id])
|
1158
|
+
Rails.logger.info "π Queued valuation request for #{processed_data[:unique_id]}"
|
1159
|
+
end
|
1160
|
+
|
1161
|
+
# Download images if provided
|
1162
|
+
if message.dig('data', 'image_urls').present?
|
1163
|
+
ImageDownloadJob.perform_later(processed_data[:unique_id], message['data']['image_urls'])
|
1164
|
+
end
|
1165
|
+
|
1166
|
+
when 'update'
|
1167
|
+
# Track significant value changes
|
1168
|
+
original_value = Asset.find_by(unique_id: processed_data[:unique_id])&.estimated_hammer
|
1169
|
+
if original_value != processed_data[:estimated_hammer]
|
1170
|
+
AssetValueChangeNotificationJob.perform_later(processed_data[:unique_id])
|
1171
|
+
end
|
1172
|
+
|
1173
|
+
when 'delete', 'destroy'
|
1174
|
+
# Clean up related data
|
1175
|
+
CleanupAssetDataJob.perform_later(processed_data[:unique_id])
|
1176
|
+
end
|
1177
|
+
|
1178
|
+
# Always update search index
|
1179
|
+
SearchIndexUpdateJob.perform_later(processed_data[:unique_id], 'Asset')
|
1180
|
+
end
|
1181
|
+
|
1182
|
+
# Subscribe to auction results
|
1183
|
+
nats_wave_subscribes_to "auction_service.lots.*",
|
1265
1184
|
field_mappings: {
|
1266
1185
|
'lot_number' => 'lot_number',
|
1267
1186
|
'hammer_price' => 'pw_contract_price',
|
@@ -1272,65 +1191,81 @@ class Asset < ApplicationRecord
|
|
1272
1191
|
'pw_contract_price' => ->(price) { (price.to_f * 100).round }, # Convert to cents
|
1273
1192
|
'pw_contract_date' => ->(date) { Time.parse(date.to_s) rescue nil }
|
1274
1193
|
},
|
1275
|
-
|
1276
|
-
|
1277
|
-
|
1278
|
-
|
1279
|
-
|
1280
|
-
|
1281
|
-
|
1282
|
-
|
1283
|
-
|
1284
|
-
|
1285
|
-
|
1286
|
-
|
1194
|
+
unique_fields: [:lot_number] do |model_name, action, processed_data, message|
|
1195
|
+
if action == 'sold'
|
1196
|
+
Rails.logger.info "π¨ Asset sold: #{processed_data[:lot_number]} for $#{processed_data[:pw_contract_price] / 100}"
|
1197
|
+
|
1198
|
+
# Create final estimate based on actual sale
|
1199
|
+
asset = Asset.find_by(lot_number: processed_data[:lot_number])
|
1200
|
+
if asset
|
1201
|
+
Estimate.create!(
|
1202
|
+
asset: asset,
|
1203
|
+
user: User.find_by(email: 'auction@system.com'),
|
1204
|
+
estimate_type: EstimateType.find_by(name: 'Auction Result'),
|
1205
|
+
status: 'current',
|
1206
|
+
value_category: 'final',
|
1207
|
+
value: processed_data[:pw_contract_price],
|
1208
|
+
version_number: 1,
|
1209
|
+
notes: "Actual auction sale price from #{processed_data[:pw_auction]}"
|
1210
|
+
)
|
1211
|
+
|
1212
|
+
# Notify stakeholders
|
1213
|
+
AuctionResultNotificationJob.perform_later(asset.id)
|
1214
|
+
end
|
1215
|
+
end
|
1216
|
+
end
|
1287
1217
|
|
1288
|
-
|
1289
|
-
|
1290
|
-
|
1291
|
-
|
1292
|
-
|
1293
|
-
|
1294
|
-
estimated_value
|
1295
|
-
|
1296
|
-
|
1297
|
-
|
1298
|
-
|
1299
|
-
|
1218
|
+
# Publish asset changes to other services
|
1219
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.assets.{action}",
|
1220
|
+
field_mappings: {
|
1221
|
+
'unique_id' => 'asset_id',
|
1222
|
+
'make' => 'manufacturer',
|
1223
|
+
'model' => 'model_name',
|
1224
|
+
'estimated_hammer' => 'estimated_value'
|
1225
|
+
},
|
1226
|
+
transformations: {
|
1227
|
+
'created_at' => lambda(&:iso8601),
|
1228
|
+
'updated_at' => lambda(&:iso8601),
|
1229
|
+
'estimated_value' => ->(cents) { cents.to_f / 100 } # Convert back to dollars
|
1230
|
+
},
|
1231
|
+
skip_fields: %w[notes edge_data source_data package_id creator_id last_updater_id],
|
1232
|
+
actions: %i[create update] do |model_name, action, published_data, record|
|
1233
|
+
Rails.logger.info "π€ Published #{action} for asset: #{record.unique_id}"
|
1234
|
+
|
1235
|
+
# Custom processing after publishing
|
1236
|
+
if action == 'update' && record.estimated_hammer_changed?
|
1237
|
+
# Notify external pricing services
|
1238
|
+
ExternalPricingService.update_asset_value(record.unique_id, record.estimated_hammer)
|
1239
|
+
end
|
1240
|
+
|
1241
|
+
# Update external inventory systems
|
1242
|
+
if record.location_changed?
|
1243
|
+
ExternalInventoryService.update_location(record.unique_id, record.location)
|
1244
|
+
end
|
1245
|
+
end
|
1300
1246
|
end
|
1247
|
+
```
|
1301
1248
|
|
1302
|
-
|
1249
|
+
### Package Management Integration
|
1250
|
+
|
1251
|
+
```ruby
|
1252
|
+
# app/models/package.rb
|
1303
1253
|
class Package < ApplicationRecord
|
1304
|
-
include NatsWave::NatsPublishable
|
1305
1254
|
include NatsWave::Concerns::Mappable
|
1306
1255
|
|
1307
1256
|
belongs_to :organization
|
1308
|
-
belongs_to :contact_user, class_name: 'User', optional: true
|
1309
1257
|
has_many :assets, dependent: :destroy
|
1310
|
-
has_many :subpackages, dependent: :destroy
|
1311
1258
|
has_many :valuation_requests, dependent: :destroy
|
1312
|
-
has_many :ims_submissions, dependent: :destroy
|
1313
1259
|
|
1314
|
-
#
|
1315
|
-
|
1316
|
-
actions: [:create, :update],
|
1317
|
-
skip_attributes: [:notes, :source_data],
|
1318
|
-
include_associations: [:organization, :assets],
|
1319
|
-
subject_prefix: 'packages',
|
1320
|
-
if: -> { workflow_status.in?(['active', 'finalized']) }
|
1321
|
-
)
|
1322
|
-
|
1323
|
-
# Sync from CRM/Project management systems
|
1324
|
-
nats_wave_maps_from 'CRMProject',
|
1260
|
+
# Subscribe to CRM project updates
|
1261
|
+
nats_wave_subscribes_to "crm_service.projects.*",
|
1325
1262
|
field_mappings: {
|
1326
1263
|
'project_id' => 'key',
|
1327
1264
|
'client_name' => 'owner_name',
|
1328
1265
|
'client_email' => 'owner_email',
|
1329
1266
|
'project_location' => 'location',
|
1330
1267
|
'project_status' => 'workflow_status',
|
1331
|
-
'
|
1332
|
-
'opportunity_reference' => 'opportunity_id',
|
1333
|
-
'contact_person' => 'primary_contact'
|
1268
|
+
'opportunity_reference' => 'opportunity_id'
|
1334
1269
|
},
|
1335
1270
|
transformations: {
|
1336
1271
|
'workflow_status' => ->(status) {
|
@@ -1342,354 +1277,108 @@ class Package < ApplicationRecord
|
|
1342
1277
|
end
|
1343
1278
|
}
|
1344
1279
|
},
|
1345
|
-
unique_fields: [:key],
|
1346
|
-
|
1347
|
-
|
1348
|
-
|
1349
|
-
|
1350
|
-
|
1351
|
-
|
1352
|
-
|
1353
|
-
|
1354
|
-
|
1355
|
-
|
1356
|
-
|
1357
|
-
|
1358
|
-
def nats_wave_metadata
|
1359
|
-
{
|
1360
|
-
organization_name: organization&.name,
|
1361
|
-
organization_key: organization&.key,
|
1362
|
-
asset_count: assets.count,
|
1363
|
-
total_estimated_value: assets.sum(:estimated_hammer),
|
1364
|
-
workflow_status: workflow_status,
|
1365
|
-
import_status: {
|
1366
|
-
requested: import_requested,
|
1367
|
-
completed: import_completed
|
1368
|
-
},
|
1369
|
-
valuation_summary: {
|
1370
|
-
reasons: valuation_reasons,
|
1371
|
-
notes_present: valuation_notes.present?
|
1372
|
-
}
|
1373
|
-
}
|
1374
|
-
end
|
1375
|
-
end
|
1376
|
-
|
1377
|
-
# app/models/estimate.rb
|
1378
|
-
class Estimate < ApplicationRecord
|
1379
|
-
include NatsWave::NatsPublishable
|
1380
|
-
include NatsWave::Concerns::Mappable
|
1381
|
-
|
1382
|
-
belongs_to :asset
|
1383
|
-
belongs_to :user
|
1384
|
-
belongs_to :estimate_type
|
1385
|
-
|
1386
|
-
# Publish estimate changes for consensus tracking
|
1387
|
-
nats_publishable(
|
1388
|
-
actions: [:create, :update],
|
1389
|
-
skip_attributes: [:notes, :source_data],
|
1390
|
-
subject_prefix: 'estimates',
|
1391
|
-
if: -> { status == 'current' && value_category == 'final' }
|
1392
|
-
)
|
1280
|
+
unique_fields: [:key] do |model_name, action, processed_data, message|
|
1281
|
+
Rails.logger.info "π CRM project sync: #{action} for #{processed_data[:key]}"
|
1282
|
+
|
1283
|
+
if action == 'create'
|
1284
|
+
# Auto-create valuation request for new projects
|
1285
|
+
ValuationRequest.create!(
|
1286
|
+
package_id: Package.find_by(key: processed_data[:key])&.id,
|
1287
|
+
user: User.find_by(email: 'system@valuation.com'),
|
1288
|
+
notes: 'Auto-created from CRM project'
|
1289
|
+
)
|
1290
|
+
end
|
1291
|
+
end
|
1393
1292
|
|
1394
|
-
#
|
1395
|
-
|
1293
|
+
# Publish package workflow changes
|
1294
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.packages.{action}",
|
1396
1295
|
field_mappings: {
|
1397
|
-
'
|
1398
|
-
'
|
1399
|
-
'
|
1400
|
-
'estimated_value' => 'value',
|
1401
|
-
'valuation_notes' => 'notes',
|
1402
|
-
'valuation_type' => 'estimate_type_id'
|
1296
|
+
'key' => 'package_key',
|
1297
|
+
'owner_name' => 'client_name',
|
1298
|
+
'workflow_status' => 'status'
|
1403
1299
|
},
|
1404
1300
|
transformations: {
|
1405
|
-
'
|
1406
|
-
'
|
1407
|
-
EstimateType.find_by(name: type_name)&.id || 1
|
1408
|
-
},
|
1409
|
-
'status' => ->(_) { 'current' },
|
1410
|
-
'value_category' => ->(_) { 'temporary' },
|
1411
|
-
'version_number' => ->(_) { 1 }
|
1301
|
+
'created_at' => lambda(&:iso8601),
|
1302
|
+
'updated_at' => lambda(&:iso8601)
|
1412
1303
|
},
|
1413
|
-
|
1414
|
-
|
1415
|
-
|
1416
|
-
|
1417
|
-
|
1304
|
+
skip_fields: %w[notes source_data],
|
1305
|
+
actions: %i[create update] do |model_name, action, published_data, record|
|
1306
|
+
Rails.logger.info "π€ Published package #{action}: #{record.key}"
|
1307
|
+
|
1308
|
+
# Notify CRM of workflow changes
|
1309
|
+
if action == 'update' && record.workflow_status_changed?
|
1310
|
+
CRMNotificationService.notify_status_change(record)
|
1311
|
+
end
|
1312
|
+
end
|
1418
1313
|
end
|
1314
|
+
```
|
1419
1315
|
|
1420
|
-
|
1316
|
+
### User & Organization Sync
|
1317
|
+
|
1318
|
+
```ruby
|
1319
|
+
# app/models/user.rb
|
1421
1320
|
class User < ApplicationRecord
|
1422
|
-
include NatsWave::NatsPublishable
|
1423
1321
|
include NatsWave::Concerns::Mappable
|
1424
1322
|
|
1425
|
-
|
1426
|
-
|
1427
|
-
has_many :estimates
|
1428
|
-
has_many :created_assets, class_name: 'Asset', foreign_key: 'creator_id'
|
1429
|
-
has_many :updated_assets, class_name: 'Asset', foreign_key: 'last_updater_id'
|
1430
|
-
|
1431
|
-
# Publish user changes for authentication sync
|
1432
|
-
nats_publishable(
|
1433
|
-
actions: [:create, :update],
|
1434
|
-
skip_attributes: [:api_auth_key, :source_details],
|
1435
|
-
subject_prefix: 'users',
|
1436
|
-
if: -> { active? }
|
1437
|
-
)
|
1438
|
-
|
1439
|
-
# Sync from authentication service
|
1440
|
-
nats_wave_maps_from 'AuthUser',
|
1323
|
+
# Subscribe to authentication service
|
1324
|
+
nats_wave_subscribes_to "auth_service.users.*",
|
1441
1325
|
field_mappings: {
|
1442
1326
|
'user_id' => 'key',
|
1443
1327
|
'email_address' => 'email',
|
1444
1328
|
'full_name' => 'name',
|
1445
|
-
'is_active' => 'active'
|
1446
|
-
'organization_key' => 'current_organization_key'
|
1329
|
+
'is_active' => 'active'
|
1447
1330
|
},
|
1448
1331
|
transformations: {
|
1449
1332
|
'email' => ->(email) { email&.downcase&.strip },
|
1450
1333
|
'name' => ->(name) { name&.strip },
|
1451
1334
|
'active' => ->(active) { active == 'true' || active == true }
|
1452
1335
|
},
|
1453
|
-
unique_fields: [:key, :email],
|
1454
|
-
|
1455
|
-
|
1456
|
-
|
1457
|
-
|
1336
|
+
unique_fields: [:key, :email] do |model_name, action, processed_data, message|
|
1337
|
+
Rails.logger.info "π€ User sync: #{action} for #{processed_data[:email]}"
|
1338
|
+
|
1339
|
+
if action == 'create'
|
1340
|
+
# Send welcome email
|
1341
|
+
UserMailer.welcome_email(processed_data[:email]).deliver_later
|
1342
|
+
end
|
1343
|
+
end
|
1344
|
+
|
1345
|
+
# Publish user activity for analytics
|
1346
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.users.{action}",
|
1347
|
+
skip_fields: %w[api_auth_key source_details],
|
1348
|
+
actions: %i[create update] do |model_name, action, published_data, record|
|
1349
|
+
Rails.logger.info "π€ Published user #{action}: #{record.email}"
|
1350
|
+
end
|
1458
1351
|
end
|
1459
1352
|
|
1460
1353
|
# app/models/organization.rb
|
1461
1354
|
class Organization < ApplicationRecord
|
1462
|
-
include NatsWave::NatsPublishable
|
1463
1355
|
include NatsWave::Concerns::Mappable
|
1464
1356
|
|
1465
|
-
|
1466
|
-
|
1467
|
-
has_many :users, through: :organization_users
|
1468
|
-
has_many :organization_estimate_types
|
1469
|
-
has_many :estimate_types, through: :organization_estimate_types
|
1470
|
-
has_one :organization_branding
|
1471
|
-
|
1472
|
-
# Publish organization changes for CRM sync
|
1473
|
-
nats_publishable(
|
1474
|
-
actions: [:create, :update],
|
1475
|
-
skip_attributes: [:api_auth_key, :workflow_config],
|
1476
|
-
subject_prefix: 'organizations',
|
1477
|
-
if: -> { active? }
|
1478
|
-
)
|
1479
|
-
|
1480
|
-
# Sync from CRM system
|
1481
|
-
nats_wave_maps_from 'CRMAccount',
|
1357
|
+
# Subscribe to CRM account updates
|
1358
|
+
nats_wave_subscribes_to "crm_service.accounts.*",
|
1482
1359
|
field_mappings: {
|
1483
1360
|
'account_id' => 'key',
|
1484
1361
|
'company_name' => 'name',
|
1485
1362
|
'is_active' => 'active',
|
1486
1363
|
'primary_contact_name' => 'contact',
|
1487
|
-
'primary_email' => 'email'
|
1488
|
-
'primary_phone' => 'phone',
|
1489
|
-
'account_group' => 'group_name'
|
1364
|
+
'primary_email' => 'email'
|
1490
1365
|
},
|
1491
1366
|
transformations: {
|
1492
1367
|
'name' => ->(name) { name&.strip },
|
1493
1368
|
'email' => ->(email) { email&.downcase&.strip },
|
1494
1369
|
'active' => ->(active) { active == 'true' || active == true }
|
1495
1370
|
},
|
1496
|
-
unique_fields: [:key, :name],
|
1497
|
-
|
1498
|
-
'source.service' => ->(service) { service != ENV['NATS_SERVICE_NAME'] }
|
1499
|
-
},
|
1500
|
-
subjects: ['crm_service.accounts.*']
|
1501
|
-
end
|
1502
|
-
|
1503
|
-
# app/models/image.rb
|
1504
|
-
class Image < ApplicationRecord
|
1505
|
-
include NatsWave::NatsPublishable
|
1506
|
-
|
1507
|
-
belongs_to :asset
|
1508
|
-
|
1509
|
-
# Publish image events for media processing
|
1510
|
-
nats_publishable(
|
1511
|
-
actions: [:create, :update],
|
1512
|
-
skip_attributes: [:source_data, :import_url],
|
1513
|
-
subject_prefix: 'images',
|
1514
|
-
if: -> { !deleted? && sizes.present? }
|
1515
|
-
)
|
1516
|
-
|
1517
|
-
private
|
1518
|
-
|
1519
|
-
def nats_wave_metadata
|
1520
|
-
{
|
1521
|
-
asset_key: asset&.key,
|
1522
|
-
package_key: asset&.package&.key,
|
1523
|
-
image_view: image_view,
|
1524
|
-
custom_view: custom_image_view,
|
1525
|
-
position: position,
|
1526
|
-
file_info: {
|
1527
|
-
original_filename: original_filename,
|
1528
|
-
file_type: file_type,
|
1529
|
-
file_size: file_size,
|
1530
|
-
dimensions: {
|
1531
|
-
width: original_x,
|
1532
|
-
height: original_y
|
1533
|
-
}
|
1534
|
-
},
|
1535
|
-
ims_submission: {
|
1536
|
-
name: ims_submission_name,
|
1537
|
-
key: ims_image_key,
|
1538
|
-
submitted_at: ims_submitted_at,
|
1539
|
-
linked_at: ims_linked_at
|
1540
|
-
}
|
1541
|
-
}
|
1542
|
-
end
|
1543
|
-
end
|
1544
|
-
```
|
1545
|
-
|
1546
|
-
### Cross-Service Workflow Example
|
1547
|
-
|
1548
|
-
```ruby
|
1549
|
-
# app/services/asset_valuation_workflow.rb
|
1550
|
-
class AssetValuationWorkflow
|
1551
|
-
def self.process_inventory_asset(message)
|
1552
|
-
# Message from inventory service creates/updates local asset
|
1553
|
-
inventory_data = message['data']
|
1554
|
-
|
1555
|
-
# Find or create asset using field mappings
|
1556
|
-
asset = Asset.find_or_initialize_by(unique_id: inventory_data['inventory_id'])
|
1557
|
-
|
1558
|
-
# Update with transformed data
|
1559
|
-
asset.assign_attributes(
|
1560
|
-
make: inventory_data['manufacturer']&.upcase,
|
1561
|
-
model: inventory_data['model_number']&.titleize,
|
1562
|
-
year: inventory_data['year_manufactured'].to_i,
|
1563
|
-
serial: inventory_data['serial_number'],
|
1564
|
-
vin: inventory_data['vin_number'],
|
1565
|
-
location: inventory_data['location_name'],
|
1566
|
-
estimated_hammer: (inventory_data['estimated_value'].to_f * 100).round
|
1567
|
-
)
|
1568
|
-
|
1569
|
-
asset.save!
|
1570
|
-
|
1571
|
-
# Automatically request valuation if needed
|
1572
|
-
if asset.estimated_hammer.nil? || asset.estimated_hammer.zero?
|
1573
|
-
ValuationRequest.create!(
|
1574
|
-
package: asset.package,
|
1575
|
-
user: User.find_by(email: 'system@valuation.com'),
|
1576
|
-
notes: 'Auto-requested from inventory sync'
|
1577
|
-
)
|
1578
|
-
end
|
1371
|
+
unique_fields: [:key, :name] do |model_name, action, processed_data, message|
|
1372
|
+
Rails.logger.info "π’ Organization sync: #{action} for #{processed_data[:name]}"
|
1579
1373
|
end
|
1580
|
-
|
1581
|
-
|
1582
|
-
|
1583
|
-
|
1584
|
-
|
1585
|
-
|
1586
|
-
return unless asset
|
1587
|
-
|
1588
|
-
asset.update!(
|
1589
|
-
pw_contract_price: (auction_data['hammer_price'].to_f * 100).round,
|
1590
|
-
pw_contract_date: Time.parse(auction_data['sale_date']),
|
1591
|
-
pw_auction: auction_data['auction_house']
|
1592
|
-
)
|
1593
|
-
|
1594
|
-
# Create final estimate based on actual sale
|
1595
|
-
Estimate.create!(
|
1596
|
-
asset: asset,
|
1597
|
-
user: User.find_by(email: 'auction@system.com'),
|
1598
|
-
estimate_type: EstimateType.find_by(name: 'Auction Result'),
|
1599
|
-
status: 'current',
|
1600
|
-
value_category: 'final',
|
1601
|
-
value: asset.pw_contract_price,
|
1602
|
-
version_number: 1,
|
1603
|
-
notes: 'Actual auction sale price'
|
1604
|
-
)
|
1605
|
-
end
|
1606
|
-
end
|
1607
|
-
|
1608
|
-
# Message handlers
|
1609
|
-
class NatsWave::MessageHandler
|
1610
|
-
def self.process(message)
|
1611
|
-
Rails.logger.info "π Processing #{message['action']} for #{message['model']} from #{message.dig('source', 'service')}"
|
1612
|
-
|
1613
|
-
case message['model']
|
1614
|
-
when 'InventoryAsset'
|
1615
|
-
AssetValuationWorkflow.process_inventory_asset(message)
|
1616
|
-
when 'AuctionLot'
|
1617
|
-
AssetValuationWorkflow.process_auction_result(message)
|
1618
|
-
when 'CRMProject'
|
1619
|
-
CRMProjectHandler.process(message)
|
1620
|
-
when 'AuthUser'
|
1621
|
-
AuthUserHandler.process(message)
|
1622
|
-
else
|
1623
|
-
# Use automatic field mapping for registered models
|
1624
|
-
super
|
1625
|
-
end
|
1626
|
-
end
|
1627
|
-
end
|
1628
|
-
```
|
1629
|
-
|
1630
|
-
### User & Organization Sync
|
1631
|
-
|
1632
|
-
```ruby
|
1633
|
-
# app/models/user.rb
|
1634
|
-
class User < ApplicationRecord
|
1635
|
-
include NatsWave::NatsPublishable
|
1636
|
-
include NatsWave::Concerns::Mappable
|
1637
|
-
|
1638
|
-
nats_publishable(
|
1639
|
-
skip_attributes: [:api_auth_key, :source_details],
|
1640
|
-
subject_prefix: 'users'
|
1641
|
-
)
|
1642
|
-
|
1643
|
-
# Sync from authentication service
|
1644
|
-
nats_wave_maps_from 'AuthUser',
|
1645
|
-
field_mappings: {
|
1646
|
-
'user_id' => 'key',
|
1647
|
-
'email_address' => 'email',
|
1648
|
-
'full_name' => 'name',
|
1649
|
-
'is_active' => 'active',
|
1650
|
-
'organization_id' => 'current_organization_key'
|
1651
|
-
},
|
1652
|
-
transformations: {
|
1653
|
-
'email' => ->(email) { email&.downcase&.strip },
|
1654
|
-
'name' => ->(name) { name&.titleize&.strip },
|
1655
|
-
'active' => ->(active) { active == 'true' || active == true }
|
1656
|
-
},
|
1657
|
-
unique_fields: [:key, :email],
|
1658
|
-
subjects: ['auth_service.users.*']
|
1659
|
-
|
1660
|
-
# Custom subscription for user activity tracking
|
1661
|
-
nats_wave_subscribes_to 'activity_service.user_actions.*' do |message|
|
1662
|
-
UserActivityTracker.track_activity(message)
|
1374
|
+
|
1375
|
+
# Publish organization changes
|
1376
|
+
nats_wave_publishes_to "#{ENV['NATS_SERVICE_NAME']}.organizations.{action}",
|
1377
|
+
skip_fields: %w[api_auth_key workflow_config],
|
1378
|
+
actions: %i[create update] do |model_name, action, published_data, record|
|
1379
|
+
Rails.logger.info "π€ Published organization #{action}: #{record.name}"
|
1663
1380
|
end
|
1664
1381
|
end
|
1665
|
-
|
1666
|
-
# app/models/organization.rb
|
1667
|
-
class Organization < ApplicationRecord
|
1668
|
-
include NatsWave::NatsPublishable
|
1669
|
-
include NatsWave::Concerns::Mappable
|
1670
|
-
|
1671
|
-
nats_publishable(
|
1672
|
-
skip_attributes: [:api_auth_key, :workflow_config],
|
1673
|
-
subject_prefix: 'organizations'
|
1674
|
-
)
|
1675
|
-
|
1676
|
-
# Sync from CRM system
|
1677
|
-
nats_wave_maps_from 'CRMOrganization',
|
1678
|
-
field_mappings: {
|
1679
|
-
'organization_id' => 'key',
|
1680
|
-
'company_name' => 'name',
|
1681
|
-
'primary_contact' => 'contact',
|
1682
|
-
'email_address' => 'email',
|
1683
|
-
'phone_number' => 'phone',
|
1684
|
-
'is_active' => 'active'
|
1685
|
-
},
|
1686
|
-
transformations: {
|
1687
|
-
'name' => ->(name) { name&.strip },
|
1688
|
-
'email' => ->(email) { email&.downcase&.strip }
|
1689
|
-
},
|
1690
|
-
unique_fields: [:key, :name],
|
1691
|
-
subjects: ['crm_service.organizations.*']
|
1692
|
-
end
|
1693
1382
|
```
|
1694
1383
|
|
1695
1384
|
## π€ Contributing
|
@@ -1729,9 +1418,8 @@ This gem is available as open source under the terms of the [MIT License](LICENS
|
|
1729
1418
|
|
1730
1419
|
## π’ About Purple Wave
|
1731
1420
|
|
1732
|
-
NatsWave is developed and maintained by [Purple Wave](https://purplewave.com) to enable seamless communication between
|
1733
|
-
our microservices and teams in the asset valuation and auction industry.
|
1421
|
+
NatsWave is developed and maintained by [Purple Wave](https://purplewave.com) to enable seamless communication between our microservices and teams in the asset valuation and auction industry.
|
1734
1422
|
|
1735
1423
|
---
|
1736
1424
|
|
1737
|
-
**Made with β€οΈ by
|
1425
|
+
**Made with β€οΈ by Jeffrey Dabo**
|