nats_wave 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +8 -0
  3. data/.idea/misc.xml +4 -0
  4. data/.idea/modules.xml +8 -0
  5. data/.idea/nats_wave.iml +169 -0
  6. data/.idea/vcs.xml +6 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +16 -0
  9. data/CHANGELOG.md +5 -0
  10. data/CODE_OF_CONDUCT.md +136 -0
  11. data/Gemfile +15 -0
  12. data/Gemfile.lock +332 -0
  13. data/LICENSE.txt +21 -0
  14. data/README.md +985 -0
  15. data/Rakefile +12 -0
  16. data/config/nats_wave.yml +65 -0
  17. data/examples/catalog_model.rb +36 -0
  18. data/examples/configuration_examples.rb +68 -0
  19. data/examples/user_model.rb +58 -0
  20. data/lib/generators/nats_wave/install_generator.rb +40 -0
  21. data/lib/generators/nats_wave/templates/README +31 -0
  22. data/lib/generators/nats_wave/templates/create_nats_wave_failed_messages.rb +20 -0
  23. data/lib/generators/nats_wave/templates/create_nats_wave_failed_subscriptions.rb +20 -0
  24. data/lib/generators/nats_wave/templates/initializer.rb +64 -0
  25. data/lib/generators/nats_wave/templates/nats_wave.yml +65 -0
  26. data/lib/nats_wave/adapters/active_record.rb +206 -0
  27. data/lib/nats_wave/adapters/datadog_metrics.rb +93 -0
  28. data/lib/nats_wave/auto_registration.rb +109 -0
  29. data/lib/nats_wave/client.rb +142 -0
  30. data/lib/nats_wave/concerns/mappable.rb +172 -0
  31. data/lib/nats_wave/concerns/publishable.rb +216 -0
  32. data/lib/nats_wave/configuration.rb +105 -0
  33. data/lib/nats_wave/database_connector.rb +50 -0
  34. data/lib/nats_wave/dead_letter_queue.rb +146 -0
  35. data/lib/nats_wave/errors.rb +27 -0
  36. data/lib/nats_wave/message_transformer.rb +95 -0
  37. data/lib/nats_wave/metrics.rb +220 -0
  38. data/lib/nats_wave/middleware/authentication.rb +65 -0
  39. data/lib/nats_wave/middleware/base.rb +19 -0
  40. data/lib/nats_wave/middleware/logging.rb +58 -0
  41. data/lib/nats_wave/middleware/validation.rb +74 -0
  42. data/lib/nats_wave/model_mapper.rb +125 -0
  43. data/lib/nats_wave/model_registry.rb +150 -0
  44. data/lib/nats_wave/publisher.rb +151 -0
  45. data/lib/nats_wave/railtie.rb +111 -0
  46. data/lib/nats_wave/schema_registry.rb +77 -0
  47. data/lib/nats_wave/subscriber.rb +161 -0
  48. data/lib/nats_wave/version.rb +5 -0
  49. data/lib/nats_wave.rb +97 -0
  50. data/lib/tasks/nats_wave.rake +360 -0
  51. data/sig/nats_wave.rbs +5 -0
  52. metadata +385 -0
@@ -0,0 +1,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