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.
- checksums.yaml +7 -0
- data/.idea/.gitignore +8 -0
- data/.idea/misc.xml +4 -0
- data/.idea/modules.xml +8 -0
- data/.idea/nats_wave.iml +169 -0
- data/.idea/vcs.xml +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +16 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +136 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +332 -0
- data/LICENSE.txt +21 -0
- data/README.md +985 -0
- data/Rakefile +12 -0
- data/config/nats_wave.yml +65 -0
- data/examples/catalog_model.rb +36 -0
- data/examples/configuration_examples.rb +68 -0
- data/examples/user_model.rb +58 -0
- data/lib/generators/nats_wave/install_generator.rb +40 -0
- data/lib/generators/nats_wave/templates/README +31 -0
- data/lib/generators/nats_wave/templates/create_nats_wave_failed_messages.rb +20 -0
- data/lib/generators/nats_wave/templates/create_nats_wave_failed_subscriptions.rb +20 -0
- data/lib/generators/nats_wave/templates/initializer.rb +64 -0
- data/lib/generators/nats_wave/templates/nats_wave.yml +65 -0
- data/lib/nats_wave/adapters/active_record.rb +206 -0
- data/lib/nats_wave/adapters/datadog_metrics.rb +93 -0
- data/lib/nats_wave/auto_registration.rb +109 -0
- data/lib/nats_wave/client.rb +142 -0
- data/lib/nats_wave/concerns/mappable.rb +172 -0
- data/lib/nats_wave/concerns/publishable.rb +216 -0
- data/lib/nats_wave/configuration.rb +105 -0
- data/lib/nats_wave/database_connector.rb +50 -0
- data/lib/nats_wave/dead_letter_queue.rb +146 -0
- data/lib/nats_wave/errors.rb +27 -0
- data/lib/nats_wave/message_transformer.rb +95 -0
- data/lib/nats_wave/metrics.rb +220 -0
- data/lib/nats_wave/middleware/authentication.rb +65 -0
- data/lib/nats_wave/middleware/base.rb +19 -0
- data/lib/nats_wave/middleware/logging.rb +58 -0
- data/lib/nats_wave/middleware/validation.rb +74 -0
- data/lib/nats_wave/model_mapper.rb +125 -0
- data/lib/nats_wave/model_registry.rb +150 -0
- data/lib/nats_wave/publisher.rb +151 -0
- data/lib/nats_wave/railtie.rb +111 -0
- data/lib/nats_wave/schema_registry.rb +77 -0
- data/lib/nats_wave/subscriber.rb +161 -0
- data/lib/nats_wave/version.rb +5 -0
- data/lib/nats_wave.rb +97 -0
- data/lib/tasks/nats_wave.rake +360 -0
- data/sig/nats_wave.rbs +5 -0
- metadata +385 -0
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWave
|
4
|
+
class ModelRegistry
|
5
|
+
@mappings = {}
|
6
|
+
@reverse_mappings = {}
|
7
|
+
@subscriptions = []
|
8
|
+
|
9
|
+
class << self
|
10
|
+
# Register a mapping from local model to external models
|
11
|
+
def register_mapping(local_model, external_mappings)
|
12
|
+
@mappings[local_model] = external_mappings
|
13
|
+
end
|
14
|
+
|
15
|
+
# Register a reverse mapping from external model to local model
|
16
|
+
def register_reverse_mapping(external_model, local_model, mapping_config)
|
17
|
+
@reverse_mappings[external_model] ||= {}
|
18
|
+
@reverse_mappings[external_model][local_model] = mapping_config
|
19
|
+
end
|
20
|
+
|
21
|
+
# Register a subscription
|
22
|
+
def register_subscription(subjects:, model:, external_model: nil, handler: nil, queue_group: nil)
|
23
|
+
subscription = {
|
24
|
+
subjects: Array(subjects),
|
25
|
+
model: model,
|
26
|
+
external_model: external_model,
|
27
|
+
handler: handler,
|
28
|
+
queue_group: queue_group,
|
29
|
+
registered_at: Time.current
|
30
|
+
}
|
31
|
+
|
32
|
+
@subscriptions << subscription
|
33
|
+
|
34
|
+
Rails.logger.debug "📡 Registered subscription: #{model} -> #{subjects.join(', ')}" if defined?(Rails)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Get all registered subscriptions
|
38
|
+
def subscriptions
|
39
|
+
@subscriptions
|
40
|
+
end
|
41
|
+
|
42
|
+
# Get subscriptions for a specific model
|
43
|
+
def subscriptions_for_model(model_name)
|
44
|
+
@subscriptions.select { |sub| sub[:model] == model_name }
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get all unique subjects to subscribe to
|
48
|
+
def all_subscription_subjects
|
49
|
+
@subscriptions.flat_map { |sub| sub[:subjects] }.uniq
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get subscription handlers grouped by subject pattern
|
53
|
+
def subscription_handlers
|
54
|
+
handlers = {}
|
55
|
+
|
56
|
+
@subscriptions.each do |subscription|
|
57
|
+
subscription[:subjects].each do |subject|
|
58
|
+
handlers[subject] ||= []
|
59
|
+
handlers[subject] << {
|
60
|
+
model: subscription[:model],
|
61
|
+
external_model: subscription[:external_model],
|
62
|
+
handler: subscription[:handler],
|
63
|
+
queue_group: subscription[:queue_group]
|
64
|
+
}
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
handlers
|
69
|
+
end
|
70
|
+
|
71
|
+
# Get local model mappings for an external model
|
72
|
+
def local_models_for(external_model)
|
73
|
+
@reverse_mappings[external_model] || {}
|
74
|
+
end
|
75
|
+
|
76
|
+
# Get external model mappings for a local model
|
77
|
+
def external_models_for(local_model)
|
78
|
+
@mappings[local_model] || {}
|
79
|
+
end
|
80
|
+
|
81
|
+
# Check if an external model can be synced
|
82
|
+
def can_sync_external_model?(external_model)
|
83
|
+
@reverse_mappings.key?(external_model)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Get all registered mappings
|
87
|
+
def all_mappings
|
88
|
+
{
|
89
|
+
local_to_external: @mappings,
|
90
|
+
external_to_local: @reverse_mappings
|
91
|
+
}
|
92
|
+
end
|
93
|
+
|
94
|
+
# Find the best local model for an external model
|
95
|
+
def best_local_model_for(external_model, data = {})
|
96
|
+
local_mappings = local_models_for(external_model)
|
97
|
+
return nil if local_mappings.empty?
|
98
|
+
|
99
|
+
# If only one mapping, use it
|
100
|
+
return local_mappings.keys.first if local_mappings.size == 1
|
101
|
+
|
102
|
+
# If multiple mappings, try to find the best match based on conditions
|
103
|
+
local_mappings.each do |local_model, config|
|
104
|
+
conditions = config[:conditions] || {}
|
105
|
+
|
106
|
+
if conditions.empty? || evaluate_conditions(conditions, data)
|
107
|
+
return local_model
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Fallback to first available
|
112
|
+
local_mappings.keys.first
|
113
|
+
end
|
114
|
+
|
115
|
+
# Clear all mappings and subscriptions (useful for testing)
|
116
|
+
def clear!
|
117
|
+
@mappings.clear
|
118
|
+
@reverse_mappings.clear
|
119
|
+
@subscriptions.clear
|
120
|
+
end
|
121
|
+
|
122
|
+
# Get subscription statistics
|
123
|
+
def subscription_stats
|
124
|
+
{
|
125
|
+
total_subscriptions: @subscriptions.size,
|
126
|
+
unique_subjects: all_subscription_subjects.size,
|
127
|
+
models_with_subscriptions: @subscriptions.map { |s| s[:model] }.uniq.size,
|
128
|
+
subscription_breakdown: @subscriptions.group_by { |s| s[:model] }.transform_values(&:size)
|
129
|
+
}
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def evaluate_conditions(conditions, data)
|
135
|
+
conditions.all? do |field, expected_value|
|
136
|
+
case expected_value
|
137
|
+
when Proc
|
138
|
+
expected_value.call(data[field])
|
139
|
+
when Regexp
|
140
|
+
expected_value.match?(data[field].to_s)
|
141
|
+
when Array
|
142
|
+
expected_value.include?(data[field])
|
143
|
+
else
|
144
|
+
data[field] == expected_value
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
begin
|
7
|
+
require 'nats/client'
|
8
|
+
rescue LoadError
|
9
|
+
# NATS not available - define a mock for testing
|
10
|
+
module NATS
|
11
|
+
def self.connect(url, options = {})
|
12
|
+
NatsClient.new
|
13
|
+
end
|
14
|
+
|
15
|
+
class NatsClient
|
16
|
+
def connected?
|
17
|
+
false
|
18
|
+
end
|
19
|
+
|
20
|
+
def publish(subject, message)
|
21
|
+
puts "NATS: Publishing to #{subject}: #{message}"
|
22
|
+
end
|
23
|
+
|
24
|
+
def subscribe(subject, options = {})
|
25
|
+
puts "NATS: Subscribing to #{subject}"
|
26
|
+
yield('{"mock": "message"}') if block_given?
|
27
|
+
Subscription.new
|
28
|
+
end
|
29
|
+
|
30
|
+
def close
|
31
|
+
true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class Subscription
|
36
|
+
def unsubscribe
|
37
|
+
true
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
module NatsWave
|
44
|
+
class Publisher
|
45
|
+
attr_reader :config, :nats_client
|
46
|
+
|
47
|
+
def initialize(config, nats_client, middleware_stack)
|
48
|
+
@config = config
|
49
|
+
@nats_client = nats_client
|
50
|
+
@middleware_stack = middleware_stack
|
51
|
+
@message_transformer = MessageTransformer.new(config)
|
52
|
+
@dead_letter_queue = DeadLetterQueue.new(config) if config.dead_letter_queue
|
53
|
+
end
|
54
|
+
|
55
|
+
def publish(subject:, model:, action:, data:, metadata: {})
|
56
|
+
return unless @config.publishing_enabled
|
57
|
+
|
58
|
+
message = build_message(subject, model, action, data, metadata)
|
59
|
+
processed_message = apply_middleware(message)
|
60
|
+
full_subject = build_full_subject(subject)
|
61
|
+
|
62
|
+
if @config.async_publishing && defined?(Concurrent)
|
63
|
+
publish_async(full_subject, processed_message)
|
64
|
+
else
|
65
|
+
publish_sync(full_subject, processed_message)
|
66
|
+
end
|
67
|
+
|
68
|
+
Metrics.increment_published_messages(full_subject)
|
69
|
+
NatsWave.logger.debug("Published message to #{full_subject}")
|
70
|
+
rescue => e
|
71
|
+
NatsWave.logger.error("Failed to publish message: #{e.message}")
|
72
|
+
@dead_letter_queue&.store_failed_message(message, e, 0)
|
73
|
+
raise PublishError, "Failed to publish message: #{e.message}"
|
74
|
+
end
|
75
|
+
|
76
|
+
def publish_batch(events)
|
77
|
+
batch_message = {
|
78
|
+
batch_id: SecureRandom.uuid,
|
79
|
+
events: events,
|
80
|
+
timestamp: Time.current.iso8601,
|
81
|
+
source: build_source_info
|
82
|
+
}
|
83
|
+
|
84
|
+
processed_message = apply_middleware(batch_message)
|
85
|
+
subject = "#{@config.default_subject_prefix}.batch"
|
86
|
+
|
87
|
+
@nats_client.publish(subject, processed_message.to_json)
|
88
|
+
|
89
|
+
Metrics.increment_published_messages(subject)
|
90
|
+
NatsWave.logger.info("Published batch with #{events.size} events")
|
91
|
+
end
|
92
|
+
|
93
|
+
def connected?
|
94
|
+
@nats_client&.connected?
|
95
|
+
end
|
96
|
+
|
97
|
+
def disconnect
|
98
|
+
# Publisher cleanup if needed
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def build_message(subject, model, action, data, metadata)
|
104
|
+
@message_transformer.build_standard_message(
|
105
|
+
subject: subject,
|
106
|
+
model: model,
|
107
|
+
action: action,
|
108
|
+
data: data,
|
109
|
+
metadata: metadata,
|
110
|
+
source: build_source_info
|
111
|
+
)
|
112
|
+
end
|
113
|
+
|
114
|
+
def build_source_info
|
115
|
+
{
|
116
|
+
service: @config.service_name,
|
117
|
+
version: @config.version,
|
118
|
+
instance_id: @config.instance_id,
|
119
|
+
environment: defined?(Rails) ? Rails.env : 'test'
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
123
|
+
def build_full_subject(subject)
|
124
|
+
if @config.default_subject_prefix && !subject.start_with?(@config.default_subject_prefix)
|
125
|
+
"#{@config.default_subject_prefix}.#{subject}"
|
126
|
+
else
|
127
|
+
subject
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def apply_middleware(message)
|
132
|
+
@middleware_stack.reduce(message) do |msg, middleware|
|
133
|
+
middleware.call(msg)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def publish_sync(subject, message)
|
138
|
+
@nats_client.publish(subject, message.to_json)
|
139
|
+
end
|
140
|
+
|
141
|
+
def publish_async(subject, message)
|
142
|
+
if defined?(Concurrent)
|
143
|
+
Concurrent::Future.execute do
|
144
|
+
@nats_client.publish(subject, message.to_json)
|
145
|
+
end
|
146
|
+
else
|
147
|
+
publish_sync(subject, message)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/railtie'
|
4
|
+
|
5
|
+
module NatsWave
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
config.nats_wave = ActiveSupport::OrderedOptions.new
|
8
|
+
|
9
|
+
initializer "nats_wave.configure" do |app|
|
10
|
+
# Load configuration from Rails config
|
11
|
+
if app.config.respond_to?(:nats_wave)
|
12
|
+
NatsWave.configure do |config|
|
13
|
+
app.config.nats_wave.each do |key, value|
|
14
|
+
config.send("#{key}=", value) if config.respond_to?("#{key}=")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Load YAML configuration if it exists
|
20
|
+
config_file = Rails.root.join('config', 'nats_wave.yml')
|
21
|
+
if File.exist?(config_file)
|
22
|
+
begin
|
23
|
+
yaml_config = YAML.load_file(config_file)[Rails.env]
|
24
|
+
|
25
|
+
NatsWave.configure do |config|
|
26
|
+
# NATS Configuration
|
27
|
+
config.nats_url = yaml_config.dig('nats', 'url') if yaml_config.dig('nats', 'url')
|
28
|
+
config.connection_pool_size = yaml_config.dig('nats', 'connection_pool_size') if yaml_config.dig('nats', 'connection_pool_size')
|
29
|
+
config.timeout = yaml_config.dig('nats', 'timeout') if yaml_config.dig('nats', 'timeout')
|
30
|
+
config.reconnect_attempts = yaml_config.dig('nats', 'reconnect_attempts') if yaml_config.dig('nats', 'reconnect_attempts')
|
31
|
+
|
32
|
+
# Service Configuration
|
33
|
+
config.service_name = yaml_config.dig('publishing', 'default_subject_prefix') if yaml_config.dig('publishing', 'default_subject_prefix')
|
34
|
+
config.version = Rails.application.class.parent_name.underscore if defined?(Rails.application.class.parent_name)
|
35
|
+
|
36
|
+
# Publishing Configuration
|
37
|
+
config.publishing_enabled = yaml_config.dig('publishing', 'enabled') unless yaml_config.dig('publishing', 'enabled').nil?
|
38
|
+
config.default_subject_prefix = yaml_config.dig('publishing', 'default_subject_prefix') if yaml_config.dig('publishing', 'default_subject_prefix')
|
39
|
+
config.batch_size = yaml_config.dig('publishing', 'batch_size') if yaml_config.dig('publishing', 'batch_size')
|
40
|
+
config.async_publishing = yaml_config.dig('publishing', 'async') unless yaml_config.dig('publishing', 'async').nil?
|
41
|
+
|
42
|
+
# Subscription Configuration
|
43
|
+
config.subscription_enabled = yaml_config.dig('subscription', 'enabled') unless yaml_config.dig('subscription', 'enabled').nil?
|
44
|
+
config.queue_group = yaml_config.dig('subscription', 'queue_group') if yaml_config.dig('subscription', 'queue_group')
|
45
|
+
|
46
|
+
# Middleware Configuration
|
47
|
+
config.middleware_authentication_enabled = yaml_config.dig('middleware', 'authentication', 'enabled') unless yaml_config.dig('middleware', 'authentication', 'enabled').nil?
|
48
|
+
config.middleware_validation_enabled = yaml_config.dig('middleware', 'validation', 'enabled') unless yaml_config.dig('middleware', 'validation', 'enabled').nil?
|
49
|
+
config.middleware_logging_enabled = yaml_config.dig('middleware', 'logging', 'enabled') unless yaml_config.dig('middleware', 'logging', 'enabled').nil?
|
50
|
+
config.auth_secret_key = yaml_config.dig('middleware', 'authentication', 'secret_key') if yaml_config.dig('middleware', 'authentication', 'secret_key')
|
51
|
+
config.schema_registry_url = yaml_config.dig('middleware', 'validation', 'schema_registry') if yaml_config.dig('middleware', 'validation', 'schema_registry')
|
52
|
+
config.log_level = yaml_config.dig('middleware', 'logging', 'level') if yaml_config.dig('middleware', 'logging', 'level')
|
53
|
+
|
54
|
+
# Error Handling Configuration
|
55
|
+
config.max_retries = yaml_config.dig('error_handling', 'max_retries') if yaml_config.dig('error_handling', 'max_retries')
|
56
|
+
config.retry_delay = yaml_config.dig('error_handling', 'retry_delay') if yaml_config.dig('error_handling', 'retry_delay')
|
57
|
+
config.dead_letter_queue = yaml_config.dig('error_handling', 'dead_letter_queue') if yaml_config.dig('error_handling', 'dead_letter_queue')
|
58
|
+
end
|
59
|
+
rescue => e
|
60
|
+
Rails.logger.warn "Failed to load NATS Wave YAML configuration: #{e.message}"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Environment-specific overrides
|
64
|
+
if Rails.env.development?
|
65
|
+
NatsWave.configure do |config|
|
66
|
+
config.log_level = "debug"
|
67
|
+
config.middleware_authentication_enabled = false
|
68
|
+
end
|
69
|
+
elsif Rails.env.test?
|
70
|
+
NatsWave.configure do |config|
|
71
|
+
config.publishing_enabled = false
|
72
|
+
config.subscription_enabled = false
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
initializer "nats_wave.active_record" do
|
79
|
+
ActiveSupport.on_load(:active_record) do
|
80
|
+
require 'nats_wave/concerns/publishable'
|
81
|
+
include NatsWave::ActiveRecordExtension
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
rake_tasks do
|
86
|
+
load "tasks/nats_wave.rake"
|
87
|
+
end
|
88
|
+
|
89
|
+
generators do
|
90
|
+
require 'generators/nats_wave/install_generator'
|
91
|
+
end
|
92
|
+
|
93
|
+
# Initialize client on Rails boot
|
94
|
+
initializer "nats_wave.initialize_client", after: :load_config_initializers do
|
95
|
+
Rails.application.config.after_initialize do
|
96
|
+
if NatsWave.configuration&.publishing_enabled || NatsWave.configuration&.subscription_enabled
|
97
|
+
Thread.new do
|
98
|
+
sleep 1 # Give Rails time to fully boot
|
99
|
+
begin
|
100
|
+
NatsWave.client
|
101
|
+
NatsWave.logger.info "NatsWave client initialized"
|
102
|
+
NatsWave.logger.info "NATS URL #{@nats_url}"
|
103
|
+
rescue => e
|
104
|
+
NatsWave.logger.error "Failed to initialize NatsWave client: #{e.message}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'uri'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module NatsWave
|
8
|
+
class SchemaRegistry
|
9
|
+
def initialize(registry_url)
|
10
|
+
@registry_url = registry_url
|
11
|
+
@schemas = {}
|
12
|
+
@uri = URI.parse(registry_url) if registry_url
|
13
|
+
end
|
14
|
+
|
15
|
+
def validate_message(message, schema_version)
|
16
|
+
return true unless @uri
|
17
|
+
|
18
|
+
schema = get_schema(schema_version)
|
19
|
+
return true unless schema
|
20
|
+
|
21
|
+
JSON::Validator.validate(schema, message)
|
22
|
+
end
|
23
|
+
|
24
|
+
def register_schema(model, version, schema)
|
25
|
+
return false unless @uri
|
26
|
+
|
27
|
+
endpoint = "/schemas/#{model}/#{version}"
|
28
|
+
response = make_request(:post, endpoint, schema.to_json)
|
29
|
+
|
30
|
+
if response.code == '200'
|
31
|
+
NatsWave.logger.info("Registered schema for #{model} v#{version}")
|
32
|
+
true
|
33
|
+
else
|
34
|
+
NatsWave.logger.error("Failed to register schema: #{response.body}")
|
35
|
+
false
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_schema(schema_version)
|
40
|
+
return @schemas[schema_version] if @schemas[schema_version]
|
41
|
+
|
42
|
+
endpoint = "/schemas/#{schema_version}"
|
43
|
+
response = make_request(:get, endpoint)
|
44
|
+
|
45
|
+
if response.code == '200'
|
46
|
+
@schemas[schema_version] = JSON.parse(response.body)
|
47
|
+
else
|
48
|
+
NatsWave.logger.warn("Schema not found: #{schema_version}")
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
rescue StandardError => e
|
52
|
+
NatsWave.logger.error("Failed to fetch schema #{schema_version}: #{e.message}")
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def make_request(method, endpoint, body = nil)
|
59
|
+
return nil unless @uri
|
60
|
+
|
61
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
62
|
+
http.use_ssl = @uri.scheme == 'https'
|
63
|
+
|
64
|
+
request = case method
|
65
|
+
when :get
|
66
|
+
Net::HTTP::Get.new(endpoint)
|
67
|
+
when :post
|
68
|
+
Net::HTTP::Post.new(endpoint)
|
69
|
+
end
|
70
|
+
|
71
|
+
request['Content-Type'] = 'application/json'
|
72
|
+
request.body = body if body
|
73
|
+
|
74
|
+
http.request(request)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWave
|
4
|
+
class Subscriber
|
5
|
+
attr_reader :config, :nats_client
|
6
|
+
|
7
|
+
def initialize(config, nats_client, middleware_stack)
|
8
|
+
@config = config
|
9
|
+
@nats_client = nats_client
|
10
|
+
@middleware_stack = middleware_stack
|
11
|
+
@database_connector = DatabaseConnector.new(config)
|
12
|
+
@model_mapper = ModelMapper.new(config)
|
13
|
+
@message_transformer = MessageTransformer.new(config)
|
14
|
+
@dead_letter_queue = DeadLetterQueue.new(config)
|
15
|
+
@subscriptions = []
|
16
|
+
@running = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def start
|
20
|
+
return if @running
|
21
|
+
return unless @config.subscription_enabled
|
22
|
+
|
23
|
+
@running = true
|
24
|
+
NatsWave.logger.info "Starting NATS subscriber for #{@config.service_name}"
|
25
|
+
|
26
|
+
@config.subscriptions.each do |subscription|
|
27
|
+
subscribe_to_subject(subscription[:subject],
|
28
|
+
subscription[:handler])
|
29
|
+
end
|
30
|
+
|
31
|
+
NatsWave.logger.info "Subscribed to #{@config.subscriptions.size} subjects"
|
32
|
+
end
|
33
|
+
|
34
|
+
def subscribe(subjects:, model_mappings: {}, handler: nil)
|
35
|
+
subjects = Array(subjects)
|
36
|
+
|
37
|
+
subjects.each do |subject|
|
38
|
+
subscribe_to_subject(subject, handler,
|
39
|
+
model_mappings)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def unsubscribe_all
|
44
|
+
@subscriptions.each(&:unsubscribe)
|
45
|
+
@subscriptions.clear
|
46
|
+
@running = false
|
47
|
+
end
|
48
|
+
|
49
|
+
def database_connected?
|
50
|
+
@database_connector.connected?
|
51
|
+
end
|
52
|
+
|
53
|
+
def disconnect
|
54
|
+
unsubscribe_all
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def subscribe_to_subject(subject_pattern, custom_handler = nil, model_mappings = {})
|
60
|
+
NatsWave.logger.info "Subscribing to: #{subject_pattern}"
|
61
|
+
|
62
|
+
subscription = @nats_client.subscribe(
|
63
|
+
subject_pattern,
|
64
|
+
queue: @config.queue_group
|
65
|
+
) do |msg|
|
66
|
+
process_message(msg,
|
67
|
+
custom_handler, model_mappings)
|
68
|
+
end
|
69
|
+
|
70
|
+
@subscriptions << subscription
|
71
|
+
end
|
72
|
+
|
73
|
+
def process_message(raw_message, custom_handler, model_mappings)
|
74
|
+
return unless should_process_message?(raw_message)
|
75
|
+
|
76
|
+
Metrics.track_processing_time(raw_message) do
|
77
|
+
message = parse_message(raw_message)
|
78
|
+
processed_message = apply_middleware(message)
|
79
|
+
|
80
|
+
if custom_handler
|
81
|
+
custom_handler.call(processed_message)
|
82
|
+
else
|
83
|
+
handle_model_sync(
|
84
|
+
processed_message, model_mappings
|
85
|
+
)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
NatsWave.logger.debug('Successfully processed message')
|
90
|
+
rescue StandardError => e
|
91
|
+
handle_error(e, raw_message, message)
|
92
|
+
end
|
93
|
+
|
94
|
+
def should_process_message?(raw_message)
|
95
|
+
# Skip messages from same service instance
|
96
|
+
message = JSON.parse(raw_message)
|
97
|
+
source = message['source'] || {}
|
98
|
+
|
99
|
+
return false if source['service'] == @config.service_name &&
|
100
|
+
source['instance_id'] == @config.instance_id
|
101
|
+
|
102
|
+
true
|
103
|
+
rescue JSON::ParserError
|
104
|
+
false
|
105
|
+
end
|
106
|
+
|
107
|
+
def parse_message(raw_message)
|
108
|
+
@message_transformer.parse_message(raw_message)
|
109
|
+
end
|
110
|
+
|
111
|
+
def apply_middleware(message)
|
112
|
+
@middleware_stack.reduce(message) do |msg, middleware|
|
113
|
+
middleware.call(msg)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def handle_model_sync(message, model_mappings)
|
118
|
+
source_model = message['model']
|
119
|
+
mapping = model_mappings[source_model] || @config.model_mappings[source_model]
|
120
|
+
|
121
|
+
return unless mapping
|
122
|
+
|
123
|
+
target_model = mapping[:target_model]
|
124
|
+
field_mappings = mapping[:field_mappings] || {}
|
125
|
+
transformations = mapping[:transformations] || {}
|
126
|
+
|
127
|
+
# Transform the data
|
128
|
+
transformed_data = @model_mapper.transform_data(
|
129
|
+
message['data'],
|
130
|
+
field_mappings,
|
131
|
+
transformations
|
132
|
+
)
|
133
|
+
|
134
|
+
# Apply to database
|
135
|
+
@database_connector.apply_change(
|
136
|
+
model: target_model,
|
137
|
+
action: message['action'],
|
138
|
+
data: transformed_data,
|
139
|
+
metadata: message['metadata']
|
140
|
+
)
|
141
|
+
|
142
|
+
NatsWave.logger.info "Synced #{source_model} -> #{target_model}: #{message['action']}"
|
143
|
+
end
|
144
|
+
|
145
|
+
def handle_error(error, raw_message, parsed_message = nil)
|
146
|
+
event_id = parsed_message&.dig('event_id') || 'unknown'
|
147
|
+
subject = parsed_message&.dig('subject') || 'unknown'
|
148
|
+
|
149
|
+
NatsWave.logger.error("Error processing message #{event_id} from #{subject}: #{error.message}")
|
150
|
+
|
151
|
+
# Send to dead letter queue
|
152
|
+
@dead_letter_queue.store_failed_message(
|
153
|
+
parsed_message || raw_message,
|
154
|
+
error,
|
155
|
+
0 # retry count
|
156
|
+
)
|
157
|
+
|
158
|
+
# Continue processing - don't raise to avoid breaking subscription
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|