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,216 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWave
|
4
|
+
module ActiveRecordExtension
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def nats_publishable(options = {})
|
9
|
+
include NatsWave::NatsPublishable
|
10
|
+
class_attribute :nats_publishing_options
|
11
|
+
self.nats_publishing_options = {
|
12
|
+
enabled: true,
|
13
|
+
actions: %i[create update
|
14
|
+
destroy],
|
15
|
+
skip_attributes: [],
|
16
|
+
include_associations: [],
|
17
|
+
async: true
|
18
|
+
}.merge(options)
|
19
|
+
|
20
|
+
# Register this model for publishing
|
21
|
+
NatsWave.configuration&.add_publication(name,
|
22
|
+
nats_publishing_options[:actions])
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
module NatsPublishable
|
28
|
+
extend ActiveSupport::Concern
|
29
|
+
|
30
|
+
included do
|
31
|
+
after_commit :publish_to_nats_on_create, on: :create, if: :should_publish_create?
|
32
|
+
after_commit :publish_to_nats_on_update, on: :update,
|
33
|
+
if: :should_publish_update?
|
34
|
+
after_commit :publish_to_nats_on_destroy, on: :destroy,
|
35
|
+
if: :should_publish_destroy?
|
36
|
+
end
|
37
|
+
|
38
|
+
class_methods do
|
39
|
+
def nats_wave_enabled?
|
40
|
+
nats_publishing_options[:enabled]
|
41
|
+
end
|
42
|
+
|
43
|
+
def nats_wave_actions
|
44
|
+
nats_publishing_options[:actions]
|
45
|
+
end
|
46
|
+
|
47
|
+
def nats_wave_skip_attributes
|
48
|
+
nats_publishing_options[:skip_attributes]
|
49
|
+
end
|
50
|
+
|
51
|
+
def nats_wave_unique_attributes
|
52
|
+
# Override this method in your models to define unique attributes for syncing
|
53
|
+
%i[email slug code external_icn].select do |attr|
|
54
|
+
column_names.include?(attr.to_s)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def nats_wave_skip_sync_attributes
|
59
|
+
# Override this method to define attributes that shouldn't be synced from other instances
|
60
|
+
%w[created_at updated_at]
|
61
|
+
end
|
62
|
+
|
63
|
+
def nats_wave_attribute_transformations
|
64
|
+
# Override this method to define custom transformations
|
65
|
+
{}
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def should_publish_create?
|
72
|
+
should_publish_action?(:create)
|
73
|
+
end
|
74
|
+
|
75
|
+
def should_publish_update?
|
76
|
+
should_publish_action?(:update) && has_significant_changes?
|
77
|
+
end
|
78
|
+
|
79
|
+
def should_publish_destroy?
|
80
|
+
should_publish_action?(:destroy)
|
81
|
+
end
|
82
|
+
|
83
|
+
def should_publish_action?(action)
|
84
|
+
return false unless self.class.nats_wave_enabled?
|
85
|
+
return false unless self.class.nats_wave_actions.include?(action)
|
86
|
+
return false if Thread.current[:skip_nats_wave_publishing]
|
87
|
+
return false if @skip_nats_wave_publishing
|
88
|
+
|
89
|
+
# Check conditional options
|
90
|
+
return false if nats_publishing_options[:if] && !instance_eval(&nats_publishing_options[:if])
|
91
|
+
|
92
|
+
return false if nats_publishing_options[:unless] && instance_eval(&nats_publishing_options[:unless])
|
93
|
+
|
94
|
+
true
|
95
|
+
end
|
96
|
+
|
97
|
+
def has_significant_changes?
|
98
|
+
skip_attrs = self.class.nats_wave_skip_attributes.map(&:to_s)
|
99
|
+
significant_changes = previous_changes.except(*skip_attrs, 'updated_at')
|
100
|
+
significant_changes.any?
|
101
|
+
end
|
102
|
+
|
103
|
+
def publish_to_nats_on_create
|
104
|
+
publish_to_nats('create', attributes, build_metadata)
|
105
|
+
end
|
106
|
+
|
107
|
+
def publish_to_nats_on_update
|
108
|
+
publish_to_nats('update', previous_changes, build_metadata)
|
109
|
+
end
|
110
|
+
|
111
|
+
def publish_to_nats_on_destroy
|
112
|
+
publish_to_nats('destroy', attributes, build_metadata)
|
113
|
+
end
|
114
|
+
|
115
|
+
def publish_to_nats(action, _data, metadata)
|
116
|
+
subject = build_nats_subject(action)
|
117
|
+
|
118
|
+
NatsWave.client.publish(
|
119
|
+
subject: subject,
|
120
|
+
model: self.class.name,
|
121
|
+
action: action,
|
122
|
+
data: publishable_attributes,
|
123
|
+
metadata: metadata
|
124
|
+
)
|
125
|
+
rescue StandardError => e
|
126
|
+
NatsWave.logger.error("Failed to publish NATS event: #{e.message}")
|
127
|
+
# Don't raise to avoid breaking the main transaction
|
128
|
+
end
|
129
|
+
|
130
|
+
def build_nats_subject(action)
|
131
|
+
prefix = nats_publishing_options&.dig(:subject_prefix)
|
132
|
+
model_name = self.class.name.underscore
|
133
|
+
|
134
|
+
if prefix
|
135
|
+
"#{prefix}.#{model_name}.#{action}"
|
136
|
+
else
|
137
|
+
"#{model_name}.#{action}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def publishable_attributes
|
142
|
+
attrs = if nats_publishing_options&.dig(:only)
|
143
|
+
attributes.slice(*nats_publishing_options[:only].map(&:to_s))
|
144
|
+
elsif nats_publishing_options&.dig(:except)
|
145
|
+
attributes.except(*nats_publishing_options[:except].map(&:to_s))
|
146
|
+
else
|
147
|
+
attributes
|
148
|
+
end
|
149
|
+
|
150
|
+
# Add associations if configured
|
151
|
+
attrs.merge!(serialize_associations) if nats_publishing_options[:include_associations].present?
|
152
|
+
|
153
|
+
attrs
|
154
|
+
end
|
155
|
+
|
156
|
+
def serialize_associations
|
157
|
+
associations = {}
|
158
|
+
|
159
|
+
self.class.reflect_on_all_associations.each do |association|
|
160
|
+
unless nats_publishing_options[:include_associations].include?(association.name)
|
161
|
+
next
|
162
|
+
end
|
163
|
+
|
164
|
+
case association.macro
|
165
|
+
when :belongs_to
|
166
|
+
if (related = send(association.name))
|
167
|
+
associations["#{association.name}_id"] =
|
168
|
+
related.id
|
169
|
+
if association.polymorphic?
|
170
|
+
associations["#{association.name}_type"] =
|
171
|
+
related.class.name
|
172
|
+
end
|
173
|
+
end
|
174
|
+
when :has_many, :has_and_belongs_to_many
|
175
|
+
if respond_to?("#{association.name}_ids")
|
176
|
+
associations["#{association.name.to_s.singularize}_ids"] =
|
177
|
+
send("#{association.name}_ids")
|
178
|
+
end
|
179
|
+
when :has_one
|
180
|
+
if (related = send(association.name))
|
181
|
+
associations["#{association.name}_id"] =
|
182
|
+
related.id
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
associations
|
188
|
+
end
|
189
|
+
|
190
|
+
def build_metadata
|
191
|
+
metadata = {
|
192
|
+
model_id: id,
|
193
|
+
model_type: self.class.name,
|
194
|
+
table_name: self.class.table_name,
|
195
|
+
primary_key: self.class.primary_key
|
196
|
+
}
|
197
|
+
|
198
|
+
# Add custom metadata if method exists
|
199
|
+
metadata.merge!(nats_wave_metadata) if respond_to?(:nats_wave_metadata,
|
200
|
+
true)
|
201
|
+
|
202
|
+
# Add configured metadata
|
203
|
+
metadata.merge!(nats_publishing_options[:metadata]) if nats_publishing_options[:metadata]
|
204
|
+
|
205
|
+
metadata
|
206
|
+
end
|
207
|
+
|
208
|
+
def skip_nats_wave_publishing!
|
209
|
+
@skip_nats_wave_publishing = true
|
210
|
+
end
|
211
|
+
|
212
|
+
def enable_nats_wave_publishing!
|
213
|
+
@skip_nats_wave_publishing = false
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'socket'
|
4
|
+
|
5
|
+
module NatsWave
|
6
|
+
class Configuration
|
7
|
+
# Core settings
|
8
|
+
attr_accessor :nats_url, :service_name, :version, :instance_id,
|
9
|
+
:database_url, :connection_pool_size, :timeout, :reconnect_attempts,
|
10
|
+
:publishing_enabled, :subscription_enabled, :default_subject_prefix,
|
11
|
+
:batch_size, :async_publishing, :queue_group,
|
12
|
+
:middleware_authentication_enabled, :middleware_validation_enabled,
|
13
|
+
:middleware_logging_enabled, :auth_secret_key, :schema_registry_url,
|
14
|
+
:max_retries, :retry_delay, :dead_letter_queue, :log_level,
|
15
|
+
:model_mappings, :field_mappings, :transformation_rules,
|
16
|
+
:subscriptions, :publications
|
17
|
+
|
18
|
+
def initialize(options = {})
|
19
|
+
@nats_url = ENV['NATS_URL']
|
20
|
+
@service_name = ENV['NATS_SERVICE_NAME']
|
21
|
+
@version = ENV['NATS_SERVICE_VERSION'] || "1.1.0"
|
22
|
+
@instance_id = ENV['NATS_INSTANCE_ID'] || Socket.gethostname
|
23
|
+
@database_url = ENV['NATS_DATABASE_URL'] || nil
|
24
|
+
@connection_pool_size = (ENV['NATS_CONNECTION_POOL_SIZE'] || 10).to_i
|
25
|
+
@timeout = (ENV['NATS_TIMEOUT'] || 5).to_i
|
26
|
+
@reconnect_attempts = (ENV['NATS_RECONNECT_ATTEMPTS'] || 3).to_i
|
27
|
+
@publishing_enabled = ENV['NATS_PUBLISHING_ENABLED'] != 'false'
|
28
|
+
@subscription_enabled = ENV['NATS_SUBSCRIPTION_ENABLED'] != 'false'
|
29
|
+
@default_subject_prefix = ENV['NATS_SUBJECT_PREFIX'] || nil
|
30
|
+
@batch_size = (ENV['NATS_BATCH_SIZE'] || 100).to_i
|
31
|
+
@async_publishing = ENV['NATS_ASYNC_PUBLISHING'] != 'false'
|
32
|
+
@queue_group = ENV['NATS_QUEUE_GROUP'] || nil
|
33
|
+
@middleware_authentication_enabled = ENV['NATS_AUTH_ENABLED'] == 'true'
|
34
|
+
@middleware_validation_enabled = ENV['NATS_VALIDATION_ENABLED'] != 'false'
|
35
|
+
@middleware_logging_enabled = ENV['NATS_LOGGING_ENABLED'] != 'false'
|
36
|
+
@auth_secret_key = ENV['NATS_AUTH_SECRET_KEY'] || nil
|
37
|
+
@schema_registry_url = ENV['NATS_SCHEMA_REGISTRY_URL'] || nil
|
38
|
+
@max_retries = (ENV['NATS_MAX_RETRIES'] || 3).to_i
|
39
|
+
@retry_delay = (ENV['NATS_RETRY_DELAY'] || 5).to_i
|
40
|
+
@dead_letter_queue = ENV['NATS_DEAD_LETTER_QUEUE'] || "failed_messages"
|
41
|
+
@log_level = ENV['NATS_LOG_LEVEL'] || "info"
|
42
|
+
|
43
|
+
# Initialize collections
|
44
|
+
@model_mappings = {}
|
45
|
+
@field_mappings = {}
|
46
|
+
@transformation_rules = {}
|
47
|
+
@subscriptions = []
|
48
|
+
@publications = []
|
49
|
+
|
50
|
+
# Override with provided options (highest priority)
|
51
|
+
options.each do |key, value|
|
52
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
53
|
+
end
|
54
|
+
|
55
|
+
# Set computed defaults
|
56
|
+
@default_subject_prefix ||= service_name
|
57
|
+
@queue_group ||= "#{service_name}_consumers"
|
58
|
+
end
|
59
|
+
|
60
|
+
def add_model_mapping(external_model, local_model, field_mappings = {})
|
61
|
+
@model_mappings[external_model] = {
|
62
|
+
target_model: local_model,
|
63
|
+
field_mappings: field_mappings
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
def add_subscription(subject_pattern, handler = nil, &block)
|
68
|
+
@subscriptions << {
|
69
|
+
subject: subject_pattern,
|
70
|
+
handler: handler || block
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
def add_publication(model_class, actions = [:create, :update, :destroy])
|
75
|
+
@publications << {
|
76
|
+
model: model_class,
|
77
|
+
actions: Array(actions)
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
def nats_server_url
|
82
|
+
# This is the URL other teams can use to subscribe to your events
|
83
|
+
uri = URI.parse(@nats_url)
|
84
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}"
|
85
|
+
end
|
86
|
+
|
87
|
+
def subject_patterns
|
88
|
+
# Returns all subject patterns this service publishes
|
89
|
+
patterns = []
|
90
|
+
@publications.each do |pub|
|
91
|
+
model_name = pub[:model].to_s.underscore
|
92
|
+
pub[:actions].each do |action|
|
93
|
+
patterns << "#{@default_subject_prefix}.#{model_name}.#{action}"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
patterns
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_h
|
100
|
+
instance_variables.each_with_object({}) do |var, hash|
|
101
|
+
hash[var.to_s.delete('@').to_sym] = instance_variable_get(var)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWave
|
4
|
+
class DatabaseConnector
|
5
|
+
def initialize(config)
|
6
|
+
@config = config
|
7
|
+
@adapter = determine_adapter
|
8
|
+
end
|
9
|
+
|
10
|
+
def apply_change(model:, action:, data:, metadata:)
|
11
|
+
case action.to_s.downcase
|
12
|
+
when 'create'
|
13
|
+
create_record(model, data, metadata)
|
14
|
+
when 'update'
|
15
|
+
update_record(model, data, metadata)
|
16
|
+
when 'delete', 'destroy'
|
17
|
+
delete_record(model, data, metadata)
|
18
|
+
else
|
19
|
+
NatsWave.logger.warn("Unknown action: #{action}")
|
20
|
+
end
|
21
|
+
rescue StandardError => e
|
22
|
+
NatsWave.logger.error("Database operation failed: #{e.message}")
|
23
|
+
raise DatabaseError, "Database operation failed: #{e.message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def connected?
|
27
|
+
@adapter.connected?
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def determine_adapter
|
33
|
+
raise ConfigurationError, 'No supported database adapter found' unless defined?(ActiveRecord)
|
34
|
+
|
35
|
+
Adapters::ActiveRecord.new(@config)
|
36
|
+
end
|
37
|
+
|
38
|
+
def create_record(model, data, metadata)
|
39
|
+
@adapter.create(model, data, metadata)
|
40
|
+
end
|
41
|
+
|
42
|
+
def update_record(model, data, metadata)
|
43
|
+
@adapter.update(model, data, metadata)
|
44
|
+
end
|
45
|
+
|
46
|
+
def delete_record(model, data, metadata)
|
47
|
+
@adapter.delete(model, data, metadata)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWave
|
4
|
+
class DeadLetterQueue
|
5
|
+
def initialize(config)
|
6
|
+
@config = config
|
7
|
+
@storage = setup_storage
|
8
|
+
@max_retries = config.max_retries
|
9
|
+
@retry_delay = config.retry_delay
|
10
|
+
end
|
11
|
+
|
12
|
+
def store_failed_message(message, error, retry_count)
|
13
|
+
failed_message = {
|
14
|
+
id: SecureRandom.uuid,
|
15
|
+
message: message.to_json,
|
16
|
+
error: error.message,
|
17
|
+
error_class: error.class.name,
|
18
|
+
error_backtrace: error.backtrace&.join("\n"),
|
19
|
+
retry_count: retry_count,
|
20
|
+
timestamp: Time.current.iso8601,
|
21
|
+
next_retry: calculate_next_retry(retry_count)
|
22
|
+
}
|
23
|
+
|
24
|
+
@storage.store(failed_message)
|
25
|
+
NatsWave.logger.error("Stored failed message in DLQ: #{failed_message[:id]}")
|
26
|
+
end
|
27
|
+
|
28
|
+
def retry_failed_messages
|
29
|
+
@storage.pending_retries.each do |failed_message|
|
30
|
+
next if failed_message[:next_retry] > Time.current
|
31
|
+
|
32
|
+
begin
|
33
|
+
retry_message(failed_message)
|
34
|
+
@storage.remove(failed_message[:id])
|
35
|
+
NatsWave.logger.info("Successfully retried message: #{failed_message[:id]}")
|
36
|
+
rescue StandardError => e
|
37
|
+
increment_retry_count(failed_message, e)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def pending_retries_count
|
43
|
+
@storage.pending_retries.count
|
44
|
+
end
|
45
|
+
|
46
|
+
def permanent_failures_count
|
47
|
+
@storage.permanent_failures.count
|
48
|
+
end
|
49
|
+
|
50
|
+
def get_failed_messages(limit = 100)
|
51
|
+
@storage.all.first(limit)
|
52
|
+
end
|
53
|
+
|
54
|
+
def remove_failed_message(id)
|
55
|
+
@storage.remove(id)
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def setup_storage
|
61
|
+
# For production, you'd want to use Redis, database, or file storage
|
62
|
+
# For now, use in-memory storage
|
63
|
+
InMemoryStorage.new
|
64
|
+
end
|
65
|
+
|
66
|
+
def calculate_next_retry(retry_count)
|
67
|
+
# Exponential backoff: 1min, 5min, 25min
|
68
|
+
delay = @retry_delay * (5 ** retry_count)
|
69
|
+
Time.current + delay
|
70
|
+
end
|
71
|
+
|
72
|
+
def retry_message(failed_message)
|
73
|
+
# This would need to be implemented based on your retry strategy
|
74
|
+
# For now, just log that we would retry
|
75
|
+
NatsWave.logger.info("Retrying message: #{failed_message[:id]}")
|
76
|
+
|
77
|
+
# In a real implementation, you would:
|
78
|
+
# 1. Re-parse the message
|
79
|
+
# 2. Re-apply middleware
|
80
|
+
# 3. Re-process the message
|
81
|
+
# 4. Handle the result
|
82
|
+
end
|
83
|
+
|
84
|
+
def increment_retry_count(failed_message, error)
|
85
|
+
failed_message[:retry_count] += 1
|
86
|
+
failed_message[:error] = error.message
|
87
|
+
failed_message[:error_class] = error.class.name
|
88
|
+
failed_message[:next_retry] = calculate_next_retry(failed_message[:retry_count])
|
89
|
+
|
90
|
+
if failed_message[:retry_count] >= @max_retries
|
91
|
+
NatsWave.logger.error("Message exceeded max retries: #{failed_message[:id]}")
|
92
|
+
@storage.mark_permanent_failure(failed_message)
|
93
|
+
else
|
94
|
+
@storage.update(failed_message)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
class InMemoryStorage
|
99
|
+
def initialize
|
100
|
+
@messages = {}
|
101
|
+
@mutex = Mutex.new
|
102
|
+
end
|
103
|
+
|
104
|
+
def store(message)
|
105
|
+
@mutex.synchronize do
|
106
|
+
@messages[message[:id]] = message
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def remove(id)
|
111
|
+
@mutex.synchronize do
|
112
|
+
@messages.delete(id)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def update(message)
|
117
|
+
@mutex.synchronize do
|
118
|
+
@messages[message[:id]] = message
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def pending_retries
|
123
|
+
@mutex.synchronize do
|
124
|
+
@messages.values.select { |msg| msg[:retry_count] < 3 }
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def permanent_failures
|
129
|
+
@mutex.synchronize do
|
130
|
+
@messages.values.select { |msg| msg[:retry_count] >= 3 }
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def all
|
135
|
+
@mutex.synchronize do
|
136
|
+
@messages.values
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def mark_permanent_failure(message)
|
141
|
+
# In a real implementation, you might move this to a different storage
|
142
|
+
update(message)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWave
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
class ConnectionError < Error; end
|
7
|
+
|
8
|
+
class PublishError < Error; end
|
9
|
+
|
10
|
+
class SubscriptionError < Error; end
|
11
|
+
|
12
|
+
class ValidationError < Error; end
|
13
|
+
|
14
|
+
class UnauthorizedError < Error; end
|
15
|
+
|
16
|
+
class ConfigurationError < Error; end
|
17
|
+
|
18
|
+
class TransformationError < Error; end
|
19
|
+
|
20
|
+
class DatabaseError < Error; end
|
21
|
+
|
22
|
+
class TimeoutError < Error; end
|
23
|
+
|
24
|
+
class MappingError < Error; end
|
25
|
+
|
26
|
+
class AuthenticationError < Error; end
|
27
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module NatsWave
|
7
|
+
class MessageTransformer
|
8
|
+
SCHEMA_VERSION = '1.0'
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
@config = config
|
12
|
+
end
|
13
|
+
|
14
|
+
def build_standard_message(subject:, model:, action:, data:, metadata:, source:)
|
15
|
+
{
|
16
|
+
event_id: SecureRandom.uuid,
|
17
|
+
timestamp: Time.current.iso8601,
|
18
|
+
source: source,
|
19
|
+
subject: subject,
|
20
|
+
model: model,
|
21
|
+
action: action,
|
22
|
+
data: sanitize_data(data),
|
23
|
+
metadata: sanitize_metadata(metadata),
|
24
|
+
schema_version: SCHEMA_VERSION
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
def parse_message(raw_message)
|
29
|
+
case raw_message
|
30
|
+
when String
|
31
|
+
JSON.parse(raw_message)
|
32
|
+
when Hash
|
33
|
+
raw_message
|
34
|
+
else
|
35
|
+
raise TransformationError, 'Invalid message format'
|
36
|
+
end
|
37
|
+
rescue JSON::ParserError => e
|
38
|
+
raise TransformationError, "Failed to parse message: #{e.message}"
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def sanitize_data(data)
|
44
|
+
case data
|
45
|
+
when Hash
|
46
|
+
data.transform_values { |v| sanitize_value(v) }
|
47
|
+
when Array
|
48
|
+
data.map { |v| sanitize_value(v) }
|
49
|
+
else
|
50
|
+
sanitize_value(data)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def sanitize_metadata(metadata)
|
55
|
+
default_metadata = {
|
56
|
+
correlation_id: SecureRandom.uuid,
|
57
|
+
timestamp: Time.current.iso8601
|
58
|
+
}
|
59
|
+
|
60
|
+
# Add Rails-specific context if available
|
61
|
+
if defined?(Rails)
|
62
|
+
default_metadata[:rails_env] = Rails.env
|
63
|
+
default_metadata[:rails_version] = Rails.version
|
64
|
+
end
|
65
|
+
|
66
|
+
# Add current user context if available (Current is a Rails 5.2+ feature)
|
67
|
+
if defined?(Current)
|
68
|
+
default_metadata[:user_id] = Current.user&.id if Current.respond_to?(:user)
|
69
|
+
default_metadata[:request_id] = Current.request_id if Current.respond_to?(:request_id)
|
70
|
+
end
|
71
|
+
|
72
|
+
default_metadata[:hostname] = Socket.gethostname
|
73
|
+
default_metadata[:process_id] = Process.pid
|
74
|
+
|
75
|
+
default_metadata.merge(sanitize_data(metadata))
|
76
|
+
end
|
77
|
+
|
78
|
+
def sanitize_value(value)
|
79
|
+
case value
|
80
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
81
|
+
value
|
82
|
+
when Time, DateTime
|
83
|
+
value.iso8601
|
84
|
+
when Date
|
85
|
+
value.to_s
|
86
|
+
when Hash
|
87
|
+
value.transform_values { |v| sanitize_value(v) }
|
88
|
+
when Array
|
89
|
+
value.map { |v| sanitize_value(v) }
|
90
|
+
else
|
91
|
+
value.to_s
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|