pigeon-rb 0.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/README.md +343 -0
- data/lib/pigeon/active_job_integration.rb +32 -0
- data/lib/pigeon/api.rb +200 -0
- data/lib/pigeon/configuration.rb +161 -0
- data/lib/pigeon/core.rb +104 -0
- data/lib/pigeon/encryption.rb +213 -0
- data/lib/pigeon/generators/hanami/migration_generator.rb +89 -0
- data/lib/pigeon/generators/rails/install_generator.rb +32 -0
- data/lib/pigeon/generators/rails/migration_generator.rb +20 -0
- data/lib/pigeon/generators/rails/templates/create_outbox_messages.rb.erb +34 -0
- data/lib/pigeon/generators/rails/templates/initializer.rb.erb +88 -0
- data/lib/pigeon/hanami_integration.rb +78 -0
- data/lib/pigeon/health_check/kafka.rb +37 -0
- data/lib/pigeon/health_check/processor.rb +70 -0
- data/lib/pigeon/health_check/queue.rb +69 -0
- data/lib/pigeon/health_check.rb +63 -0
- data/lib/pigeon/logging/structured_logger.rb +181 -0
- data/lib/pigeon/metrics/collector.rb +200 -0
- data/lib/pigeon/mock_producer.rb +18 -0
- data/lib/pigeon/models/adapters/active_record_adapter.rb +133 -0
- data/lib/pigeon/models/adapters/rom_adapter.rb +150 -0
- data/lib/pigeon/models/outbox_message.rb +182 -0
- data/lib/pigeon/monitoring.rb +113 -0
- data/lib/pigeon/outbox.rb +61 -0
- data/lib/pigeon/processor/background_processor.rb +109 -0
- data/lib/pigeon/processor.rb +798 -0
- data/lib/pigeon/publisher.rb +524 -0
- data/lib/pigeon/railtie.rb +29 -0
- data/lib/pigeon/schema.rb +35 -0
- data/lib/pigeon/security.rb +30 -0
- data/lib/pigeon/serializer.rb +77 -0
- data/lib/pigeon/tasks/pigeon.rake +64 -0
- data/lib/pigeon/trace_api.rb +37 -0
- data/lib/pigeon/tracing/core.rb +119 -0
- data/lib/pigeon/tracing/messaging.rb +144 -0
- data/lib/pigeon/tracing.rb +107 -0
- data/lib/pigeon/version.rb +5 -0
- data/lib/pigeon.rb +52 -0
- metadata +127 -0
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-configurable"
|
4
|
+
require "logger"
|
5
|
+
|
6
|
+
module Pigeon
|
7
|
+
# Configuration class for Pigeon using dry-configurable
|
8
|
+
class Configuration
|
9
|
+
extend Dry::Configurable
|
10
|
+
|
11
|
+
# Karafka client ID
|
12
|
+
setting :client_id, default: "pigeon"
|
13
|
+
|
14
|
+
# Kafka seed brokers configuration
|
15
|
+
setting :kafka_brokers, default: ["localhost:9092"]
|
16
|
+
|
17
|
+
# Maximum number of retries for failed messages
|
18
|
+
setting :max_retries, default: 10
|
19
|
+
|
20
|
+
# Base retry delay (will be used with exponential backoff)
|
21
|
+
setting :retry_delay, default: 30 # seconds
|
22
|
+
|
23
|
+
# Maximum retry delay
|
24
|
+
setting :max_retry_delay, default: 86_400 # 24 hours
|
25
|
+
|
26
|
+
# Whether to encrypt message payloads
|
27
|
+
setting :encrypt_payload, default: false
|
28
|
+
|
29
|
+
# Encryption key for payload encryption
|
30
|
+
setting :encryption_key, default: nil
|
31
|
+
|
32
|
+
# Sensitive fields to mask in logs
|
33
|
+
setting :sensitive_fields, default: []
|
34
|
+
|
35
|
+
# Retention period for processed messages
|
36
|
+
setting :retention_period, default: 7 # days
|
37
|
+
|
38
|
+
# Logger instance
|
39
|
+
setting :logger, default: Logger.new($stdout).tap { |l| l.level = Logger::INFO }
|
40
|
+
|
41
|
+
# Metrics collector
|
42
|
+
setting :metrics_collector, default: nil
|
43
|
+
|
44
|
+
# Tracer for OpenTelemetry
|
45
|
+
setting :tracer, default: nil
|
46
|
+
|
47
|
+
# Karafka additional configuration
|
48
|
+
setting :karafka_config, default: {}
|
49
|
+
|
50
|
+
# Schema validation configuration
|
51
|
+
setting :schema_validation_enabled, default: false
|
52
|
+
|
53
|
+
# Registered JSON schemas for validation
|
54
|
+
setting :schemas, default: {}
|
55
|
+
|
56
|
+
# Schema registry URL (if using a schema registry)
|
57
|
+
setting :schema_registry_url, default: nil
|
58
|
+
|
59
|
+
# Dead letter queue configuration
|
60
|
+
setting :dead_letter_queue_enabled, default: true
|
61
|
+
|
62
|
+
# Custom dead letter queue topic suffix
|
63
|
+
setting :dead_letter_queue_suffix, default: ".dlq"
|
64
|
+
|
65
|
+
# Error classification for retry strategy
|
66
|
+
setting :error_classifications, default: {
|
67
|
+
# Transient errors - retry with normal backoff
|
68
|
+
transient: [
|
69
|
+
"Kafka::ConnectionError",
|
70
|
+
"Kafka::NetworkError",
|
71
|
+
"Kafka::UnknownError",
|
72
|
+
"Kafka::LeaderNotAvailable",
|
73
|
+
"Kafka::NotLeaderForPartition"
|
74
|
+
],
|
75
|
+
# Permanent errors - mark as failed after fewer retries
|
76
|
+
permanent: [
|
77
|
+
"Kafka::InvalidTopic",
|
78
|
+
"Kafka::TopicAuthorizationFailed",
|
79
|
+
"Kafka::MessageSizeTooLarge"
|
80
|
+
],
|
81
|
+
# Application errors - mark as failed immediately
|
82
|
+
application: [
|
83
|
+
"Pigeon::Serializer::ValidationError",
|
84
|
+
"JSON::ParserError"
|
85
|
+
]
|
86
|
+
}
|
87
|
+
|
88
|
+
class << self
|
89
|
+
# Reset the configuration to default values
|
90
|
+
# @return [void]
|
91
|
+
def reset_config
|
92
|
+
# For dry-configurable 1.x
|
93
|
+
if respond_to?(:settings)
|
94
|
+
settings.each_key do |key|
|
95
|
+
config[key] = settings[key].default
|
96
|
+
end
|
97
|
+
# For dry-configurable 0.x
|
98
|
+
elsif respond_to?(:config_option_definitions)
|
99
|
+
config_option_definitions.each_key do |key|
|
100
|
+
config[key] = config_option_definitions[key].default_value
|
101
|
+
end
|
102
|
+
else
|
103
|
+
# Simplest approach - just create a new configuration
|
104
|
+
@config = Dry::Configurable::Config.new
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Register a JSON schema for validation
|
109
|
+
# @param name [String, Symbol] Schema name
|
110
|
+
# @param schema [Hash, String] JSON schema
|
111
|
+
# @return [void]
|
112
|
+
def register_schema(name, schema)
|
113
|
+
config.schemas[name.to_sym] = schema
|
114
|
+
end
|
115
|
+
|
116
|
+
# Get a registered schema
|
117
|
+
# @param name [String, Symbol] Schema name
|
118
|
+
# @return [Hash, String, nil] JSON schema or nil if not found
|
119
|
+
def schema(name)
|
120
|
+
config.schemas[name.to_sym]
|
121
|
+
end
|
122
|
+
|
123
|
+
# Register a sensitive field for masking
|
124
|
+
# @param field [String, Symbol] Field name
|
125
|
+
# @return [void]
|
126
|
+
def register_sensitive_field(field)
|
127
|
+
config.sensitive_fields << field.to_sym unless config.sensitive_fields.include?(field.to_sym)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Register multiple sensitive fields for masking
|
131
|
+
# @param fields [Array<String, Symbol>] Field names
|
132
|
+
# @return [void]
|
133
|
+
def register_sensitive_fields(fields)
|
134
|
+
fields.each { |field| register_sensitive_field(field) }
|
135
|
+
end
|
136
|
+
|
137
|
+
# Register an error class for a specific classification
|
138
|
+
# @param error_class [String, Class] Error class name or class
|
139
|
+
# @param classification [Symbol] Error classification (:transient, :permanent, :application)
|
140
|
+
# @return [void]
|
141
|
+
def register_error_classification(error_class, classification)
|
142
|
+
error_name = error_class.is_a?(Class) ? error_class.name : error_class.to_s
|
143
|
+
config.error_classifications[classification.to_sym] ||= []
|
144
|
+
config.error_classifications[classification.to_sym] << error_name
|
145
|
+
end
|
146
|
+
|
147
|
+
# Get the classification for an error
|
148
|
+
# @param error [StandardError] Error instance
|
149
|
+
# @return [Symbol] Error classification (:transient, :permanent, :application, or :unknown)
|
150
|
+
def classify_error(error)
|
151
|
+
error_name = error.class.name
|
152
|
+
|
153
|
+
config.error_classifications.each do |classification, error_classes|
|
154
|
+
return classification if error_classes.include?(error_name)
|
155
|
+
end
|
156
|
+
|
157
|
+
:unknown
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
data/lib/pigeon/core.rb
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pigeon
|
4
|
+
# Core functionality for Pigeon
|
5
|
+
module Core
|
6
|
+
# Configure the gem
|
7
|
+
# @yield [config] Configuration instance
|
8
|
+
# @example
|
9
|
+
# Pigeon.configure do |config|
|
10
|
+
# config.client_id = "my-application"
|
11
|
+
# config.kafka_brokers = ["kafka1:9092", "kafka2:9092"]
|
12
|
+
# config.max_retries = 5
|
13
|
+
# end
|
14
|
+
def self.configure
|
15
|
+
yield(Configuration.config) if block_given?
|
16
|
+
initialize_karafka if @karafka_initialized.nil?
|
17
|
+
end
|
18
|
+
|
19
|
+
# Get the configuration
|
20
|
+
# @return [Dry::Configurable::Config]
|
21
|
+
def self.config
|
22
|
+
Configuration.config
|
23
|
+
end
|
24
|
+
|
25
|
+
# Initialize the Karafka producer
|
26
|
+
# @return [Karafka::Producer]
|
27
|
+
def self.initialize_karafka # rubocop:disable Metrics/AbcSize
|
28
|
+
return @karafka_producer if @karafka_initialized
|
29
|
+
|
30
|
+
# Configure Karafka
|
31
|
+
begin
|
32
|
+
Karafka::Setup::Config.setup do |karafka_config|
|
33
|
+
karafka_config.client_id = config.client_id
|
34
|
+
|
35
|
+
# Set required Kafka configuration if not provided
|
36
|
+
if !config.karafka_config[:kafka] || config.karafka_config[:kafka].empty?
|
37
|
+
karafka_config.kafka = {
|
38
|
+
"bootstrap.servers": config.kafka_brokers.join(",")
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
# Apply any additional Karafka configuration
|
43
|
+
config.karafka_config.each do |key, value|
|
44
|
+
karafka_config.public_send("#{key}=", value) if karafka_config.respond_to?("#{key}=")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
@karafka_initialized = true
|
49
|
+
@karafka_producer = Karafka.producer
|
50
|
+
rescue StandardError => e
|
51
|
+
config.logger.error("Failed to initialize Karafka: #{e.message}")
|
52
|
+
# Return a mock producer for testing
|
53
|
+
@karafka_initialized = true
|
54
|
+
@karafka_producer = MockProducer.new
|
55
|
+
end
|
56
|
+
|
57
|
+
@karafka_producer
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get the Karafka producer instance
|
61
|
+
# @return [Karafka::Producer]
|
62
|
+
def self.karafka_producer
|
63
|
+
initialize_karafka unless @karafka_initialized
|
64
|
+
@karafka_producer
|
65
|
+
end
|
66
|
+
|
67
|
+
# Create a new publisher instance
|
68
|
+
# @return [Pigeon::Publisher]
|
69
|
+
def self.publisher
|
70
|
+
Publisher.new
|
71
|
+
end
|
72
|
+
|
73
|
+
# Create a new processor instance
|
74
|
+
# @param auto_start [Boolean] Whether to automatically start processing pending messages
|
75
|
+
# @return [Pigeon::Processor]
|
76
|
+
def self.processor(auto_start: false)
|
77
|
+
Processor.new(auto_start: auto_start)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Start processing pending messages
|
81
|
+
# @param batch_size [Integer] Number of messages to process in one batch
|
82
|
+
# @param interval [Integer] Interval in seconds between processing batches
|
83
|
+
# @param thread_count [Integer] Number of threads to use for processing
|
84
|
+
# @return [Boolean] Whether processing was started
|
85
|
+
def self.start_processing(batch_size: 100, interval: 5, thread_count: 2)
|
86
|
+
@processor ||= processor
|
87
|
+
@processor.start_processing(batch_size: batch_size, interval: interval, thread_count: thread_count)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Stop processing pending messages
|
91
|
+
# @return [Boolean] Whether processing was stopped
|
92
|
+
def self.stop_processing
|
93
|
+
return false unless @processor
|
94
|
+
|
95
|
+
@processor.stop_processing
|
96
|
+
end
|
97
|
+
|
98
|
+
# Check if processing is running
|
99
|
+
# @return [Boolean] Whether processing is running
|
100
|
+
def self.processing?
|
101
|
+
@processor&.processing? || false
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "base64"
|
5
|
+
|
6
|
+
module Pigeon
|
7
|
+
# Encryption module for handling payload encryption and decryption
|
8
|
+
module Encryption
|
9
|
+
DEFAULT_CIPHER = "aes-256-gcm"
|
10
|
+
|
11
|
+
# Encrypt a payload
|
12
|
+
# @param payload [String] Payload to encrypt
|
13
|
+
# @param encryption_key [String] Encryption key
|
14
|
+
# @param encryption_iv [String, nil] Initialization vector (optional)
|
15
|
+
# @return [Hash] Encrypted payload with metadata
|
16
|
+
def self.encrypt(payload, encryption_key = nil, encryption_iv = nil)
|
17
|
+
# Return the payload as is if encryption is disabled
|
18
|
+
return payload unless Pigeon.config.encrypt_payload
|
19
|
+
|
20
|
+
# Get the encryption key
|
21
|
+
key = encryption_key || self.encryption_key
|
22
|
+
raise EncryptionError, "Encryption key is required" unless key
|
23
|
+
|
24
|
+
# Generate a random IV if not provided (GCM uses 12-byte IV)
|
25
|
+
iv = encryption_iv || OpenSSL::Random.random_bytes(12)
|
26
|
+
|
27
|
+
# Encrypt the payload using AES-256-GCM
|
28
|
+
cipher = OpenSSL::Cipher.new(DEFAULT_CIPHER)
|
29
|
+
cipher.encrypt
|
30
|
+
cipher.key = key
|
31
|
+
cipher.iv = iv
|
32
|
+
|
33
|
+
# Encrypt the payload
|
34
|
+
encrypted_data = cipher.update(payload) + cipher.final
|
35
|
+
|
36
|
+
# Get the authentication tag
|
37
|
+
auth_tag = cipher.auth_tag
|
38
|
+
|
39
|
+
# Encode the data, IV, and auth tag
|
40
|
+
encoded_data = Base64.strict_encode64(encrypted_data)
|
41
|
+
encoded_iv = Base64.strict_encode64(iv)
|
42
|
+
encoded_auth_tag = Base64.strict_encode64(auth_tag)
|
43
|
+
|
44
|
+
# Return the encrypted payload with metadata
|
45
|
+
{
|
46
|
+
data: encoded_data,
|
47
|
+
iv: encoded_iv,
|
48
|
+
auth_tag: encoded_auth_tag,
|
49
|
+
cipher: DEFAULT_CIPHER
|
50
|
+
}
|
51
|
+
rescue StandardError => e
|
52
|
+
raise EncryptionError, "Failed to encrypt payload: #{e.message}"
|
53
|
+
end
|
54
|
+
|
55
|
+
# Decrypt a payload
|
56
|
+
# @param encrypted_payload [Hash, String] Encrypted payload with metadata
|
57
|
+
# @param encryption_key [String] Encryption key
|
58
|
+
# @return [String] Decrypted payload
|
59
|
+
def self.decrypt(encrypted_payload, encryption_key = nil)
|
60
|
+
# Return the payload as is if it's not encrypted
|
61
|
+
return encrypted_payload if encrypted_payload.is_a?(String)
|
62
|
+
|
63
|
+
# Get the encryption key
|
64
|
+
key = encryption_key || self.encryption_key
|
65
|
+
raise EncryptionError, "Encryption key is required" unless key
|
66
|
+
|
67
|
+
begin
|
68
|
+
# Extract the encrypted data, IV, and auth tag
|
69
|
+
encoded_data = encrypted_payload[:data]
|
70
|
+
encoded_iv = encrypted_payload[:iv]
|
71
|
+
encoded_auth_tag = encrypted_payload[:auth_tag]
|
72
|
+
|
73
|
+
# Decode the data, IV, and auth tag
|
74
|
+
encrypted_data = Base64.strict_decode64(encoded_data)
|
75
|
+
iv = Base64.strict_decode64(encoded_iv)
|
76
|
+
auth_tag = Base64.strict_decode64(encoded_auth_tag) if encoded_auth_tag
|
77
|
+
|
78
|
+
# Decrypt using AES-256-GCM
|
79
|
+
decipher = OpenSSL::Cipher.new(DEFAULT_CIPHER)
|
80
|
+
decipher.decrypt
|
81
|
+
decipher.key = key
|
82
|
+
decipher.iv = iv
|
83
|
+
decipher.auth_tag = auth_tag if auth_tag
|
84
|
+
|
85
|
+
# Decrypt the payload
|
86
|
+
decipher.update(encrypted_data) + decipher.final
|
87
|
+
rescue StandardError => e
|
88
|
+
raise DecryptionError, "Failed to decrypt payload: #{e.message}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Mask sensitive data in a payload
|
93
|
+
# @param payload [Hash, String] Payload to mask
|
94
|
+
# @param sensitive_fields [Array<String, Symbol>] Fields to mask
|
95
|
+
# @return [Hash, String] Masked payload
|
96
|
+
def self.mask_payload(payload, sensitive_fields = nil)
|
97
|
+
# Return the payload as is if no sensitive fields are specified
|
98
|
+
return payload if payload.is_a?(String) || sensitive_fields.nil? || sensitive_fields.empty?
|
99
|
+
|
100
|
+
# Convert the payload to a hash if it's a string
|
101
|
+
payload_hash = payload.is_a?(String) ? ::JSON.parse(payload) : payload.dup
|
102
|
+
|
103
|
+
# Mask sensitive fields
|
104
|
+
MaskingHelper.mask_fields(payload_hash, sensitive_fields)
|
105
|
+
rescue StandardError => e
|
106
|
+
Pigeon.config.logger.warn("Failed to mask payload: #{e.message}")
|
107
|
+
payload
|
108
|
+
end
|
109
|
+
|
110
|
+
# Get the encryption key from the configuration
|
111
|
+
# @return [String, nil] Encryption key
|
112
|
+
def self.encryption_key
|
113
|
+
key = key_from_config
|
114
|
+
key ||= key_from_environment
|
115
|
+
key ||= key_from_rails_credentials
|
116
|
+
key ||= key_from_hanami_settings
|
117
|
+
key
|
118
|
+
end
|
119
|
+
|
120
|
+
# Custom error classes
|
121
|
+
class EncryptionError < StandardError; end
|
122
|
+
class DecryptionError < StandardError; end
|
123
|
+
|
124
|
+
private_class_method def self.key_from_config
|
125
|
+
Pigeon.config.encryption_key
|
126
|
+
end
|
127
|
+
|
128
|
+
private_class_method def self.key_from_environment
|
129
|
+
ENV.fetch("PIGEON_ENCRYPTION_KEY", nil)
|
130
|
+
end
|
131
|
+
|
132
|
+
private_class_method def self.key_from_rails_credentials
|
133
|
+
return nil unless defined?(Rails)
|
134
|
+
return nil unless Rails.respond_to?(:application)
|
135
|
+
return nil unless Rails.application.respond_to?(:credentials)
|
136
|
+
return nil unless Rails.application.credentials.respond_to?(:pigeon)
|
137
|
+
|
138
|
+
Rails.application.credentials.pigeon[:encryption_key]
|
139
|
+
end
|
140
|
+
|
141
|
+
private_class_method def self.key_from_hanami_settings
|
142
|
+
return nil unless defined?(Hanami)
|
143
|
+
return nil unless Hanami.respond_to?(:app)
|
144
|
+
return nil unless Hanami.app.respond_to?(:[])
|
145
|
+
return nil unless Hanami.app["settings"].respond_to?(:pigeon)
|
146
|
+
|
147
|
+
Hanami.app["settings"].pigeon.encryption_key
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Helper module for masking sensitive data
|
152
|
+
module MaskingHelper
|
153
|
+
# Mask fields in a hash
|
154
|
+
# @param hash [Hash] Hash to mask
|
155
|
+
# @param sensitive_fields [Array<String, Symbol>] Fields to mask
|
156
|
+
# @return [Hash] Masked hash
|
157
|
+
def self.mask_fields(hash, sensitive_fields)
|
158
|
+
hash.each_with_object({}) do |(key, value), result|
|
159
|
+
result[key] = determine_masked_value(key, value, sensitive_fields)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Determine the masked value for a key-value pair
|
164
|
+
# @param key [Object] The key
|
165
|
+
# @param value [Object] The value
|
166
|
+
# @param sensitive_fields [Array<String, Symbol>] Fields to mask
|
167
|
+
# @return [Object] The masked or original value
|
168
|
+
def self.determine_masked_value(key, value, sensitive_fields)
|
169
|
+
key_sym = key.to_sym
|
170
|
+
key_str = key.to_s
|
171
|
+
|
172
|
+
if sensitive_fields.include?(key_sym) || sensitive_fields.include?(key_str)
|
173
|
+
# Mask the value
|
174
|
+
mask_value(value)
|
175
|
+
elsif value.is_a?(Hash)
|
176
|
+
# Recursively mask nested hashes
|
177
|
+
mask_fields(value, sensitive_fields)
|
178
|
+
elsif value.is_a?(Array)
|
179
|
+
# Recursively mask arrays of hashes
|
180
|
+
value.map { |v| v.is_a?(Hash) ? mask_fields(v, sensitive_fields) : v }
|
181
|
+
else
|
182
|
+
# Keep the value as is
|
183
|
+
value
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Mask a value
|
188
|
+
# @param value [Object] Value to mask
|
189
|
+
# @return [String] Masked value
|
190
|
+
def self.mask_value(value)
|
191
|
+
return "[REDACTED]" if value.nil?
|
192
|
+
|
193
|
+
case value
|
194
|
+
when String
|
195
|
+
# Mask the string with asterisks, keeping the first and last characters
|
196
|
+
if value.length <= 4
|
197
|
+
"*" * value.length
|
198
|
+
else
|
199
|
+
"#{value[0]}#{value[1..-2].gsub(/[^[:space:]]/, '*')}#{value[-1]}"
|
200
|
+
end
|
201
|
+
when Numeric
|
202
|
+
# Mask numbers with asterisks
|
203
|
+
"*" * value.to_s.length
|
204
|
+
when TrueClass, FalseClass
|
205
|
+
# Don't mask booleans
|
206
|
+
value
|
207
|
+
else
|
208
|
+
# Mask everything else
|
209
|
+
"[REDACTED]"
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
|
5
|
+
module Pigeon
|
6
|
+
module Generators
|
7
|
+
module Hanami
|
8
|
+
# Generator for creating the outbox message table migration for Hanami applications
|
9
|
+
class MigrationGenerator
|
10
|
+
attr_reader :app_name
|
11
|
+
|
12
|
+
def initialize(app_name = nil)
|
13
|
+
@app_name = app_name || detect_app_name
|
14
|
+
end
|
15
|
+
|
16
|
+
# Generate the migration file
|
17
|
+
# @return [String] Path to the generated file
|
18
|
+
def generate
|
19
|
+
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
20
|
+
filename = "db/migrations/#{timestamp}_create_outbox_messages.rb"
|
21
|
+
|
22
|
+
# Create the migrations directory if it doesn't exist
|
23
|
+
FileUtils.mkdir_p("db/migrations")
|
24
|
+
|
25
|
+
# Write the migration file
|
26
|
+
File.write(filename, migration_content)
|
27
|
+
|
28
|
+
filename
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# Detect the Hanami application name
|
34
|
+
# @return [String] Application name
|
35
|
+
def detect_app_name
|
36
|
+
if defined?(Hanami) && Hanami.respond_to?(:app)
|
37
|
+
Hanami.app.name.to_s
|
38
|
+
else
|
39
|
+
"App"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Generate the migration content
|
44
|
+
# @return [String] Migration content
|
45
|
+
def migration_content
|
46
|
+
<<~RUBY
|
47
|
+
# frozen_string_literal: true
|
48
|
+
|
49
|
+
ROM::SQL.migration do
|
50
|
+
change do
|
51
|
+
create_table :outbox_messages do
|
52
|
+
primary_key :id, type: :uuid
|
53
|
+
|
54
|
+
# Message metadata
|
55
|
+
column :topic, String, null: false
|
56
|
+
column :key, String
|
57
|
+
column :headers, :jsonb
|
58
|
+
column :partition, Integer
|
59
|
+
|
60
|
+
# Message content
|
61
|
+
column :payload, String, text: true, null: false
|
62
|
+
|
63
|
+
# Processing metadata
|
64
|
+
column :status, String, null: false, default: "pending"
|
65
|
+
column :retry_count, Integer, null: false, default: 0
|
66
|
+
column :max_retries, Integer
|
67
|
+
column :error_message, String, text: true
|
68
|
+
column :correlation_id, String
|
69
|
+
|
70
|
+
# Timestamps
|
71
|
+
column :created_at, DateTime, null: false
|
72
|
+
column :updated_at, DateTime, null: false
|
73
|
+
column :published_at, DateTime
|
74
|
+
column :next_retry_at, DateTime
|
75
|
+
|
76
|
+
# Indexes
|
77
|
+
index :status
|
78
|
+
index :next_retry_at
|
79
|
+
index :correlation_id
|
80
|
+
index :created_at
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
RUBY
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
|
5
|
+
module Pigeon
|
6
|
+
module Generators
|
7
|
+
module Rails
|
8
|
+
# Generator for installing Pigeon in a Rails application
|
9
|
+
class InstallGenerator < ::Rails::Generators::Base
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
11
|
+
desc "Installs Pigeon configuration files"
|
12
|
+
|
13
|
+
def create_initializer_file
|
14
|
+
template "initializer.rb.erb", "config/initializers/pigeon.rb"
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_migration_file
|
18
|
+
generate "pigeon:rails:migration"
|
19
|
+
end
|
20
|
+
|
21
|
+
def display_post_install_message
|
22
|
+
say "\nPigeon has been installed! 🐦", :green
|
23
|
+
say "\nNext steps:"
|
24
|
+
say " 1. Review the configuration in config/initializers/pigeon.rb"
|
25
|
+
say " 2. Run migrations with: rails db:migrate"
|
26
|
+
say " 3. Start using Pigeon in your application"
|
27
|
+
say "\nFor more information, visit: https://github.com/khaile/pigeon"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
|
5
|
+
module Pigeon
|
6
|
+
module Generators
|
7
|
+
module Rails
|
8
|
+
# Generator for creating the outbox message table migration for Rails applications
|
9
|
+
class MigrationGenerator < ::Rails::Generators::Base
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
11
|
+
desc "Creates a migration for the outbox message table"
|
12
|
+
|
13
|
+
def create_migration_file
|
14
|
+
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
15
|
+
template "create_outbox_messages.rb.erb", "db/migrate/#{timestamp}_create_outbox_messages.rb"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateOutboxMessages < ActiveRecord::Migration[<%= ::Rails::VERSION::MAJOR %>.<%= ::Rails::VERSION::MINOR %>]
|
4
|
+
def change
|
5
|
+
create_table :outbox_messages, id: :uuid do |t|
|
6
|
+
# Message metadata
|
7
|
+
t.string :topic, null: false
|
8
|
+
t.string :key
|
9
|
+
t.jsonb :headers
|
10
|
+
t.integer :partition
|
11
|
+
|
12
|
+
# Message content
|
13
|
+
t.text :payload, null: false
|
14
|
+
|
15
|
+
# Processing metadata
|
16
|
+
t.string :status, null: false, default: "pending"
|
17
|
+
t.integer :retry_count, null: false, default: 0
|
18
|
+
t.integer :max_retries
|
19
|
+
t.text :error_message
|
20
|
+
t.string :correlation_id
|
21
|
+
|
22
|
+
# Timestamps
|
23
|
+
t.timestamps
|
24
|
+
t.datetime :published_at
|
25
|
+
t.datetime :next_retry_at
|
26
|
+
end
|
27
|
+
|
28
|
+
# Add indexes for efficient querying
|
29
|
+
add_index :outbox_messages, :status
|
30
|
+
add_index :outbox_messages, :next_retry_at
|
31
|
+
add_index :outbox_messages, :correlation_id
|
32
|
+
add_index :outbox_messages, :created_at
|
33
|
+
end
|
34
|
+
end
|