shared_broker 0.1.0 → 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 +4 -4
- data/README.md +48 -18
- data/lib/shared_broker/adapters/base.rb +15 -0
- data/lib/shared_broker/adapters/in_memory.rb +51 -0
- data/lib/shared_broker/adapters/kafka.rb +71 -0
- data/lib/shared_broker/adapters/rabbit_mq.rb +84 -0
- data/lib/shared_broker/adapters/redis.rb +64 -0
- data/lib/shared_broker/cipher.rb +69 -0
- data/lib/shared_broker/circuit_breaker.rb +66 -0
- data/lib/shared_broker/telemetry.rb +19 -0
- data/lib/shared_broker/validation.rb +32 -0
- data/lib/shared_broker/version.rb +1 -1
- data/lib/shared_broker.rb +29 -5
- metadata +27 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a691e6fa4ea7eb0d9b7974a55ed217c25a325be29c7beba0211d05633e947c02
|
|
4
|
+
data.tar.gz: '09abf8886190595bbcf09d7faeaf0676f848c763b8efef6ed092c8b23eac5853'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96d856ee56c3d1db971a0a751920d03b65c3f67922b77ef67e42443f2586cc6e6063a5301fff1e050e8f2b81ab334e4479d80739604938c76f1880824692b097
|
|
7
|
+
data.tar.gz: 46d39087d7e875d84cd8a82223e68edadd965651e795c013c1da85cbfc847663fe594ed90bf8b1f60c5eb7ade7f14056f4aed3fbb5fe2a46e43fa5bc7aaf7ed0
|
data/README.md
CHANGED
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
`SharedBroker` is a high-performance Ruby library designed to simplify event-based communication (asynchronous messaging) and telemetry (observability) in Rails microservice architectures.
|
|
4
4
|
|
|
5
|
-
The library implements the **Adapter Pattern** to decouple your application from physical queue providers
|
|
5
|
+
The library implements the **Adapter Pattern** to decouple your application from physical queue providers, allowing easy broker swapping and clean synchronous testing with an in-memory adapter.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## Key Features
|
|
10
10
|
|
|
11
|
-
- **Pluggable Messaging**: Adapter pattern
|
|
12
|
-
-
|
|
13
|
-
-
|
|
11
|
+
- **Pluggable Messaging**: Adapter pattern supporting:
|
|
12
|
+
- `InMemory`: Synchronous local simulation for fast TDD testing (no inline external I/O stubs required).
|
|
13
|
+
- `RabbitMQ`: Robust connection using the `bunny` gem.
|
|
14
|
+
- `Kafka`: High-throughput adapter using the `kafka` gem.
|
|
15
|
+
- `Redis`: Light-weight Pub/Sub broker using the `redis` gem.
|
|
16
|
+
- **Resilience & Fault Tolerance**:
|
|
17
|
+
- **Automatic Retry**: Automatic retry mechanism on message processing failures using exponential backoff.
|
|
18
|
+
- **Dead Letter Queue (DLQ)**: Messages that exhaust their retries are automatically moved to a DLQ (`#{queue_name}.dlq` or a custom topic/list depending on the adapter) containing error metadata headers.
|
|
19
|
+
- **Circuit Breaker**: Integrated thread-safe Circuit Breaker wrapping message publication to prevent cascading failures.
|
|
20
|
+
- **Security & Data Validation**:
|
|
21
|
+
- **Strict Schema Validation**: Integration with `dry-schema` to validate message structures on both publish (boundaries out) and subscribe (boundaries in).
|
|
22
|
+
- **Transparent Payload Encryption**: Payloads are automatically encrypted at rest using AES-256-GCM via `SharedBroker.encryption_key`.
|
|
14
23
|
- **Integrated OpenTelemetry**: Centralized SDK configuration with auto-instrumentation for all supported libraries (ActiveRecord, Bunny, Faraday, Rails, PG, etc.).
|
|
15
24
|
|
|
16
25
|
---
|
|
@@ -40,20 +49,42 @@ Create an initializer in your Rails application (`config/initializers/shared_bro
|
|
|
40
49
|
```ruby
|
|
41
50
|
require "shared_broker"
|
|
42
51
|
|
|
43
|
-
# 1. Configure
|
|
52
|
+
# 1. Configure Validation Schemas (dry-schema)
|
|
53
|
+
user_created_schema = Dry::Schema.Params do
|
|
54
|
+
required(:id).filled(:integer)
|
|
55
|
+
required(:email).filled(:string)
|
|
56
|
+
end
|
|
57
|
+
SharedBroker::Validation.register("user.created", user_created_schema)
|
|
58
|
+
|
|
59
|
+
# 2. Configure Payload Encryption Key (AES-256-GCM)
|
|
60
|
+
# Expects a 32-byte string. Default key is used in development if ENV is not set.
|
|
61
|
+
SharedBroker.encryption_key = ENV.fetch("SHARED_BROKER_ENCRYPTION_KEY") { "a" * 32 }
|
|
62
|
+
|
|
63
|
+
# 3. Configure the Adapter based on Environment
|
|
44
64
|
if Rails.env.test?
|
|
45
65
|
# In-memory adapter prevents external queue dependency during unit tests
|
|
46
66
|
BROKER_ADAPTER = SharedBroker::Adapters::InMemory.new
|
|
67
|
+
elsif Rails.env.production?
|
|
68
|
+
# High-throughput production setup using Kafka
|
|
69
|
+
BROKER_ADAPTER = SharedBroker::Adapters::Kafka.new(seed_brokers: ["kafka-1:9092", "kafka-2:9092"])
|
|
47
70
|
else
|
|
48
|
-
# Connects to
|
|
71
|
+
# Connects to RabbitMQ or Redis for development
|
|
49
72
|
amqp_url = ENV.fetch("RABBITMQ_URL") { "amqp://guest:guest@localhost:5672" }
|
|
50
73
|
BROKER_ADAPTER = SharedBroker::Adapters::RabbitMQ.new(amqp_url: amqp_url)
|
|
51
74
|
end
|
|
52
75
|
|
|
53
|
-
#
|
|
54
|
-
|
|
76
|
+
# 4. Instantiate the Client by Injecting the Adapter and optional custom Circuit Breaker configuration
|
|
77
|
+
custom_circuit_breaker = SharedBroker::CircuitBreaker.new(
|
|
78
|
+
failure_threshold: 5, # trip circuit after 5 failures
|
|
79
|
+
recovery_timeout: 30 # wait 30 seconds before attempting recovery
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
SPOT_BROKER = SharedBroker::Client.new(
|
|
83
|
+
adapter: BROKER_ADAPTER,
|
|
84
|
+
circuit_breaker: custom_circuit_breaker
|
|
85
|
+
)
|
|
55
86
|
|
|
56
|
-
#
|
|
87
|
+
# 5. Initialize Telemetry (OpenTelemetry)
|
|
57
88
|
SharedBroker::Telemetry.configure(service_name: "my_microservice")
|
|
58
89
|
```
|
|
59
90
|
|
|
@@ -62,25 +93,24 @@ SharedBroker::Telemetry.configure(service_name: "my_microservice")
|
|
|
62
93
|
## Usage
|
|
63
94
|
|
|
64
95
|
### Publishing Events
|
|
65
|
-
Send
|
|
96
|
+
Send events by passing the topic name and a structured payload (must be a `Hash`):
|
|
66
97
|
|
|
67
98
|
```ruby
|
|
68
99
|
event_data = {
|
|
69
100
|
id: 1,
|
|
70
|
-
|
|
71
|
-
latitude: 48.8584,
|
|
72
|
-
longitude: 2.2945
|
|
101
|
+
email: "test@example.com"
|
|
73
102
|
}
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
# The payload will be validated against its dry-schema, encrypted, and published safely.
|
|
105
|
+
SPOT_BROKER.publish("user.created", event_data)
|
|
76
106
|
```
|
|
77
107
|
|
|
78
|
-
### Subscribing to Events (Consumer)
|
|
79
|
-
To start a persistent event subscriber daemon, register a queue associated with the topic:
|
|
108
|
+
### Subscribing to Events (Consumer with Retry and DLQ)
|
|
109
|
+
To start a persistent event subscriber daemon, register a queue/group name associated with the topic. You can customize the retries and backoff rate:
|
|
80
110
|
|
|
81
111
|
```ruby
|
|
82
|
-
SPOT_BROKER.subscribe("
|
|
83
|
-
puts "
|
|
112
|
+
SPOT_BROKER.subscribe("user.created", "my_consumption_queue", max_retries: 3, backoff_base: 2) do |payload|
|
|
113
|
+
puts "Decrypted event successfully validated & consumed! ID: #{payload[:id]}"
|
|
84
114
|
# execute your business logic here...
|
|
85
115
|
end
|
|
86
116
|
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SharedBroker
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
def publish(topic, message, correlation_id: nil)
|
|
7
|
+
raise NotImplementedError, "#{self.class.name} must implement #publish"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
|
|
11
|
+
raise NotImplementedError, "#{self.class.name} must implement #subscribe"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module SharedBroker
|
|
7
|
+
module Adapters
|
|
8
|
+
class InMemory < Base
|
|
9
|
+
def initialize
|
|
10
|
+
@storage = Hash.new { |h, k| h[k] = [] }
|
|
11
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def publish(topic, message, correlation_id: nil)
|
|
15
|
+
msg_with_metadata = message.merge(_correlation_id: correlation_id)
|
|
16
|
+
@storage[topic] << msg_with_metadata
|
|
17
|
+
|
|
18
|
+
@subscribers[topic].each do |sub|
|
|
19
|
+
attempts = 0
|
|
20
|
+
begin
|
|
21
|
+
sub[:block].call(msg_with_metadata)
|
|
22
|
+
rescue => e
|
|
23
|
+
attempts += 1
|
|
24
|
+
if attempts <= sub[:max_retries]
|
|
25
|
+
# Sleep briefly or not at all in memory to keep tests fast
|
|
26
|
+
sleep(0.001 * sub[:backoff_base]**attempts)
|
|
27
|
+
retry
|
|
28
|
+
else
|
|
29
|
+
dlq_topic = "#{sub[:queue_name]}.dlq"
|
|
30
|
+
dlq_msg = msg_with_metadata.merge(
|
|
31
|
+
_x_original_routing_key: topic,
|
|
32
|
+
_x_failed_at: Time.now.utc.iso8601,
|
|
33
|
+
_x_exception_class: e.class.name,
|
|
34
|
+
_x_exception_message: e.message
|
|
35
|
+
)
|
|
36
|
+
@storage[dlq_topic] << dlq_msg
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
|
|
43
|
+
@subscribers[topic] << { queue_name: queue_name, max_retries: max_retries, backoff_base: backoff_base, block: block }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def published_messages(topic)
|
|
47
|
+
@storage[topic]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module SharedBroker
|
|
8
|
+
module Adapters
|
|
9
|
+
class Kafka < Base
|
|
10
|
+
def initialize(seed_brokers:, client_id: "shared_broker")
|
|
11
|
+
begin
|
|
12
|
+
require "kafka"
|
|
13
|
+
rescue LoadError
|
|
14
|
+
raise unless defined?(::Kafka)
|
|
15
|
+
end
|
|
16
|
+
@kafka = ::Kafka.new(seed_brokers, client_id: client_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def publish(topic, message, correlation_id: nil)
|
|
20
|
+
unless message.is_a?(Hash)
|
|
21
|
+
raise ArgumentError, "Expected message to be a Hash, got #{message.class} with value #{message.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
headers = {}
|
|
25
|
+
headers["correlation_id"] = correlation_id if correlation_id
|
|
26
|
+
|
|
27
|
+
@kafka.deliver_message(message.to_json, topic: topic, headers: headers)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
|
|
31
|
+
consumer = @kafka.consumer(group_id: queue_name)
|
|
32
|
+
consumer.subscribe(topic)
|
|
33
|
+
|
|
34
|
+
Thread.new do
|
|
35
|
+
consumer.each_message do |message|
|
|
36
|
+
data = JSON.parse(message.value, symbolize_names: true)
|
|
37
|
+
if message.headers && message.headers["correlation_id"]
|
|
38
|
+
data[:_correlation_id] = message.headers["correlation_id"]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attempts = 0
|
|
42
|
+
begin
|
|
43
|
+
block.call(data)
|
|
44
|
+
rescue => e
|
|
45
|
+
attempts += 1
|
|
46
|
+
if attempts <= max_retries
|
|
47
|
+
sleep(backoff_base**attempts)
|
|
48
|
+
retry
|
|
49
|
+
else
|
|
50
|
+
publish_to_dlq(topic, queue_name, message.value, message.headers, e)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def publish_to_dlq(original_topic, queue_name, payload, original_headers, exception)
|
|
60
|
+
dlq_topic = "#{original_topic}.#{queue_name}.dlq"
|
|
61
|
+
headers = (original_headers || {}).merge(
|
|
62
|
+
"x_original_topic" => original_topic,
|
|
63
|
+
"x_failed_at" => Time.now.utc.iso8601,
|
|
64
|
+
"x_exception_class" => exception.class.name,
|
|
65
|
+
"x_exception_message" => exception.message
|
|
66
|
+
)
|
|
67
|
+
@kafka.deliver_message(payload, topic: dlq_topic, headers: headers)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bunny"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module SharedBroker
|
|
9
|
+
module Adapters
|
|
10
|
+
class RabbitMQ < Base
|
|
11
|
+
EXCHANGE_NAME = "shared_broker_events"
|
|
12
|
+
|
|
13
|
+
def initialize(amqp_url:)
|
|
14
|
+
@connection = Bunny.new(amqp_url)
|
|
15
|
+
@connection.start
|
|
16
|
+
@channel = @connection.create_channel
|
|
17
|
+
@exchange = @channel.topic(EXCHANGE_NAME, durable: true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def publish(topic, message, correlation_id: nil)
|
|
21
|
+
unless message.is_a?(Hash)
|
|
22
|
+
raise ArgumentError, "Message must be a Hash, got #{message.class} with value #{message.inspect}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
options = { routing_key: topic }
|
|
26
|
+
options[:correlation_id] = correlation_id if correlation_id
|
|
27
|
+
@exchange.publish(message.to_json, options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
|
|
31
|
+
queue = @channel.queue(queue_name, durable: true)
|
|
32
|
+
queue.bind(@exchange, routing_key: topic)
|
|
33
|
+
|
|
34
|
+
queue.subscribe(manual_ack: true) do |delivery_info, metadata, payload|
|
|
35
|
+
data = JSON.parse(payload, symbolize_names: true)
|
|
36
|
+
if metadata.respond_to?(:correlation_id) && metadata.correlation_id
|
|
37
|
+
data[:_correlation_id] = metadata.correlation_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attempts = 0
|
|
41
|
+
begin
|
|
42
|
+
block.call(data)
|
|
43
|
+
@channel.acknowledge(delivery_info.delivery_tag, false)
|
|
44
|
+
rescue => e
|
|
45
|
+
attempts += 1
|
|
46
|
+
if attempts <= max_retries
|
|
47
|
+
sleep(backoff_base**attempts)
|
|
48
|
+
retry
|
|
49
|
+
else
|
|
50
|
+
publish_to_dlq(queue_name, payload, delivery_info, metadata, e)
|
|
51
|
+
@channel.acknowledge(delivery_info.delivery_tag, false)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def close
|
|
58
|
+
@channel.close if @channel
|
|
59
|
+
@connection.close if @connection
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def publish_to_dlq(queue_name, payload, delivery_info, metadata, exception)
|
|
65
|
+
dlq_name = "#{queue_name}.dlq"
|
|
66
|
+
dlq_queue = @channel.queue(dlq_name, durable: true)
|
|
67
|
+
|
|
68
|
+
headers = {
|
|
69
|
+
x_original_routing_key: delivery_info.routing_key,
|
|
70
|
+
x_failed_at: Time.now.utc.iso8601,
|
|
71
|
+
x_exception_class: exception.class.name,
|
|
72
|
+
x_exception_message: exception.message
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@channel.default_exchange.publish(
|
|
76
|
+
payload,
|
|
77
|
+
routing_key: dlq_queue.name,
|
|
78
|
+
correlation_id: metadata.correlation_id,
|
|
79
|
+
headers: headers
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module SharedBroker
|
|
8
|
+
module Adapters
|
|
9
|
+
class Redis < Base
|
|
10
|
+
def initialize(redis_url:)
|
|
11
|
+
begin
|
|
12
|
+
require "redis"
|
|
13
|
+
rescue LoadError
|
|
14
|
+
raise unless defined?(::Redis)
|
|
15
|
+
end
|
|
16
|
+
@redis = ::Redis.new(url: redis_url)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def publish(topic, message, correlation_id: nil)
|
|
20
|
+
unless message.is_a?(Hash)
|
|
21
|
+
raise ArgumentError, "Expected message to be a Hash, got #{message.class} with value #{message.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
payload = message.merge(_correlation_id: correlation_id)
|
|
25
|
+
@redis.publish(topic, payload.to_json)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
|
|
29
|
+
Thread.new do
|
|
30
|
+
@redis.subscribe(topic) do |on|
|
|
31
|
+
on.message do |_channel, msg_json|
|
|
32
|
+
data = JSON.parse(msg_json, symbolize_names: true)
|
|
33
|
+
attempts = 0
|
|
34
|
+
begin
|
|
35
|
+
block.call(data)
|
|
36
|
+
rescue => e
|
|
37
|
+
attempts += 1
|
|
38
|
+
if attempts <= max_retries
|
|
39
|
+
sleep(backoff_base**attempts)
|
|
40
|
+
retry
|
|
41
|
+
else
|
|
42
|
+
publish_to_dlq(topic, queue_name, msg_json, e)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def publish_to_dlq(topic, queue_name, payload_json, exception)
|
|
53
|
+
dlq_key = "dlq:#{topic}:#{queue_name}"
|
|
54
|
+
dlq_payload = {
|
|
55
|
+
payload: JSON.parse(payload_json, symbolize_names: true),
|
|
56
|
+
x_failed_at: Time.now.utc.iso8601,
|
|
57
|
+
x_exception_class: exception.class.name,
|
|
58
|
+
x_exception_message: exception.message
|
|
59
|
+
}
|
|
60
|
+
@redis.rpush(dlq_key, dlq_payload.to_json)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module SharedBroker
|
|
8
|
+
module Cipher
|
|
9
|
+
class DecryptionError < StandardError; end
|
|
10
|
+
|
|
11
|
+
ALGORITHM = "aes-256-gcm"
|
|
12
|
+
|
|
13
|
+
def self.encrypt(payload_hash, key)
|
|
14
|
+
return payload_hash unless key
|
|
15
|
+
|
|
16
|
+
unless payload_hash.is_a?(Hash)
|
|
17
|
+
raise ArgumentError, "Expected payload_hash to be a Hash, got #{payload_hash.class} with value #{payload_hash.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# We don't want to encrypt correlation ID or special metadata keys if we want the broker to read them,
|
|
21
|
+
# but we want to encrypt the actual payload data.
|
|
22
|
+
# Let's extract metadata starting with underscore and encrypt the rest.
|
|
23
|
+
metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
|
|
24
|
+
data_to_encrypt = payload_hash.reject { |k, _| k.to_s.start_with?("_") }
|
|
25
|
+
|
|
26
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM)
|
|
27
|
+
cipher.encrypt
|
|
28
|
+
cipher.key = key
|
|
29
|
+
iv = cipher.random_iv
|
|
30
|
+
|
|
31
|
+
encrypted_data = cipher.update(data_to_encrypt.to_json) + cipher.final
|
|
32
|
+
auth_tag = cipher.auth_tag
|
|
33
|
+
|
|
34
|
+
metadata.merge(
|
|
35
|
+
_encrypted: true,
|
|
36
|
+
_iv: Base64.strict_encode64(iv),
|
|
37
|
+
_auth_tag: Base64.strict_encode64(auth_tag),
|
|
38
|
+
_data: Base64.strict_encode64(encrypted_data)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.decrypt(payload_hash, key)
|
|
43
|
+
return payload_hash unless payload_hash.is_a?(Hash) && payload_hash[:_encrypted]
|
|
44
|
+
return payload_hash unless key
|
|
45
|
+
|
|
46
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM)
|
|
47
|
+
cipher.decrypt
|
|
48
|
+
cipher.key = key
|
|
49
|
+
cipher.iv = Base64.strict_decode64(payload_hash[:_iv])
|
|
50
|
+
cipher.auth_tag = Base64.strict_decode64(payload_hash[:_auth_tag])
|
|
51
|
+
|
|
52
|
+
encrypted_bytes = Base64.strict_decode64(payload_hash[:_data])
|
|
53
|
+
decrypted_json = cipher.update(encrypted_bytes) + cipher.final
|
|
54
|
+
|
|
55
|
+
decrypted_data = JSON.parse(decrypted_json, symbolize_names: true)
|
|
56
|
+
|
|
57
|
+
# Merge back the unencrypted metadata
|
|
58
|
+
metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
|
|
59
|
+
metadata.delete(:_encrypted)
|
|
60
|
+
metadata.delete(:_iv)
|
|
61
|
+
metadata.delete(:_auth_tag)
|
|
62
|
+
metadata.delete(:_data)
|
|
63
|
+
|
|
64
|
+
decrypted_data.merge(metadata)
|
|
65
|
+
rescue => e
|
|
66
|
+
raise DecryptionError, "Failed to decrypt payload. Error: #{e.message}. Offending payload: #{payload_hash.inspect}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module SharedBroker
|
|
6
|
+
class CircuitBreaker
|
|
7
|
+
class OpenError < StandardError; end
|
|
8
|
+
|
|
9
|
+
attr_reader :state, :failure_threshold, :recovery_timeout, :failure_count
|
|
10
|
+
|
|
11
|
+
def initialize(failure_threshold: 5, recovery_timeout: 30)
|
|
12
|
+
@failure_threshold = failure_threshold
|
|
13
|
+
@recovery_timeout = recovery_timeout
|
|
14
|
+
@state = :closed
|
|
15
|
+
@failure_count = 0
|
|
16
|
+
@last_failure_time = nil
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
check_state!
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
result = yield
|
|
25
|
+
success!
|
|
26
|
+
result
|
|
27
|
+
rescue => e
|
|
28
|
+
record_failure!
|
|
29
|
+
raise e
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def check_state!
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
if @state == :open
|
|
38
|
+
if Time.now.utc - @last_failure_time > @recovery_timeout
|
|
39
|
+
@state = :half_open
|
|
40
|
+
else
|
|
41
|
+
raise OpenError, "Circuit is open. Refusing to execute command."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def success!
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
if @state == :half_open || @state == :closed
|
|
50
|
+
@state = :closed
|
|
51
|
+
@failure_count = 0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record_failure!
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@failure_count += 1
|
|
59
|
+
@last_failure_time = Time.now.utc
|
|
60
|
+
if @failure_count >= @failure_threshold
|
|
61
|
+
@state = :open
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "opentelemetry/instrumentation/all"
|
|
5
|
+
|
|
6
|
+
module SharedBroker
|
|
7
|
+
module Telemetry
|
|
8
|
+
def self.configure(service_name:)
|
|
9
|
+
unless service_name.is_a?(String) && !service_name.empty?
|
|
10
|
+
raise ArgumentError, "service_name must be a non-empty String, got #{service_name.inspect}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
OpenTelemetry::SDK.configure do |config|
|
|
14
|
+
config.service_name = service_name
|
|
15
|
+
config.use_all
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-schema"
|
|
4
|
+
|
|
5
|
+
module SharedBroker
|
|
6
|
+
module Validation
|
|
7
|
+
class ValidationError < StandardError; end
|
|
8
|
+
|
|
9
|
+
@schemas = {}
|
|
10
|
+
|
|
11
|
+
def self.register(topic, schema)
|
|
12
|
+
unless schema.respond_to?(:call)
|
|
13
|
+
raise ArgumentError, "Expected schema to respond to :call, got #{schema.class} with value #{schema.inspect}"
|
|
14
|
+
end
|
|
15
|
+
@schemas[topic.to_s] = schema
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.validate!(topic, message)
|
|
19
|
+
schema = @schemas[topic.to_s]
|
|
20
|
+
return unless schema
|
|
21
|
+
|
|
22
|
+
result = schema.call(message)
|
|
23
|
+
unless result.success?
|
|
24
|
+
raise ValidationError, "Schema validation failed for topic #{topic.inspect}. Expected keys: #{schema.rules.keys.inspect}, got payload: #{message.inspect}. Errors: #{result.errors.to_h.inspect}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.clear
|
|
29
|
+
@schemas.clear
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/shared_broker.rb
CHANGED
|
@@ -2,26 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "shared_broker/version"
|
|
4
4
|
require_relative "shared_broker/telemetry"
|
|
5
|
+
require_relative "shared_broker/circuit_breaker"
|
|
6
|
+
require_relative "shared_broker/validation"
|
|
7
|
+
require_relative "shared_broker/cipher"
|
|
5
8
|
require_relative "shared_broker/adapters/base"
|
|
6
9
|
require_relative "shared_broker/adapters/in_memory"
|
|
7
10
|
require_relative "shared_broker/adapters/rabbit_mq"
|
|
11
|
+
require_relative "shared_broker/adapters/kafka"
|
|
12
|
+
require_relative "shared_broker/adapters/redis"
|
|
8
13
|
|
|
9
14
|
module SharedBroker
|
|
15
|
+
class << self
|
|
16
|
+
attr_accessor :encryption_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Default key for development/test if not set
|
|
20
|
+
@encryption_key = ENV.fetch("SHARED_BROKER_ENCRYPTION_KEY") { "a" * 32 }
|
|
21
|
+
|
|
10
22
|
class Client
|
|
11
|
-
|
|
23
|
+
attr_reader :circuit_breaker
|
|
24
|
+
|
|
25
|
+
def initialize(adapter:, circuit_breaker: nil)
|
|
12
26
|
unless adapter.respond_to?(:publish) && adapter.respond_to?(:subscribe)
|
|
13
27
|
raise ArgumentError, "Expected adapter to respond to :publish and :subscribe, got #{adapter.class} with value #{adapter.inspect}"
|
|
14
28
|
end
|
|
15
29
|
|
|
16
30
|
@adapter = adapter
|
|
31
|
+
@circuit_breaker = circuit_breaker || CircuitBreaker.new
|
|
17
32
|
end
|
|
18
33
|
|
|
19
|
-
def publish(topic, message)
|
|
20
|
-
|
|
34
|
+
def publish(topic, message, correlation_id: nil)
|
|
35
|
+
SharedBroker::Validation.validate!(topic, message)
|
|
36
|
+
encrypted_msg = SharedBroker::Cipher.encrypt(message, SharedBroker.encryption_key)
|
|
37
|
+
|
|
38
|
+
@circuit_breaker.run do
|
|
39
|
+
@adapter.publish(topic, encrypted_msg, correlation_id: correlation_id)
|
|
40
|
+
end
|
|
21
41
|
end
|
|
22
42
|
|
|
23
|
-
def subscribe(topic, queue_name, &block)
|
|
24
|
-
@adapter.subscribe(topic, queue_name,
|
|
43
|
+
def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
|
|
44
|
+
@adapter.subscribe(topic, queue_name, max_retries: max_retries, backoff_base: backoff_base) do |raw_message|
|
|
45
|
+
decrypted_msg = SharedBroker::Cipher.decrypt(raw_message, SharedBroker.encryption_key)
|
|
46
|
+
SharedBroker::Validation.validate!(topic, decrypted_msg)
|
|
47
|
+
block.call(decrypted_msg)
|
|
48
|
+
end
|
|
25
49
|
end
|
|
26
50
|
end
|
|
27
51
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shared_broker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gemini Antigravity
|
|
@@ -24,6 +24,20 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '2.22'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: dry-schema
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.13'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.13'
|
|
27
41
|
- !ruby/object:Gem::Dependency
|
|
28
42
|
name: opentelemetry-api
|
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -119,14 +133,23 @@ files:
|
|
|
119
133
|
- README.md
|
|
120
134
|
- Rakefile
|
|
121
135
|
- lib/shared_broker.rb
|
|
136
|
+
- lib/shared_broker/adapters/base.rb
|
|
137
|
+
- lib/shared_broker/adapters/in_memory.rb
|
|
138
|
+
- lib/shared_broker/adapters/kafka.rb
|
|
139
|
+
- lib/shared_broker/adapters/rabbit_mq.rb
|
|
140
|
+
- lib/shared_broker/adapters/redis.rb
|
|
141
|
+
- lib/shared_broker/cipher.rb
|
|
142
|
+
- lib/shared_broker/circuit_breaker.rb
|
|
143
|
+
- lib/shared_broker/telemetry.rb
|
|
144
|
+
- lib/shared_broker/validation.rb
|
|
122
145
|
- lib/shared_broker/version.rb
|
|
123
146
|
- sig/shared_broker.rbs
|
|
124
|
-
homepage: https://github.com/
|
|
147
|
+
homepage: https://github.com/wesleyskap/shared_broker
|
|
125
148
|
licenses:
|
|
126
149
|
- MIT
|
|
127
150
|
metadata:
|
|
128
|
-
source_code_uri: https://github.com/
|
|
129
|
-
changelog_uri: https://github.com/
|
|
151
|
+
source_code_uri: https://github.com/wesleyskap/shared_broker
|
|
152
|
+
changelog_uri: https://github.com/wesleyskap/shared_broker/blob/main/CHANGELOG.md
|
|
130
153
|
post_install_message:
|
|
131
154
|
rdoc_options: []
|
|
132
155
|
require_paths:
|