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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 113868a35ebcb87b89d4ce8b0ba3079276ffdb8a07a519ec5ee5cbb28d605f24
4
- data.tar.gz: 5d7cbddbf655269f55bc9b752172de08d52bbcf8d3864ef1164c9458731ae66b
3
+ metadata.gz: a691e6fa4ea7eb0d9b7974a55ed217c25a325be29c7beba0211d05633e947c02
4
+ data.tar.gz: '09abf8886190595bbcf09d7faeaf0676f848c763b8efef6ed092c8b23eac5853'
5
5
  SHA512:
6
- metadata.gz: dd263b0bf50aec4e4ac83e85de4c8fadf8a59bc8ddbcdc4195862bc791aeb2770c866f15cfecec5633313b457897923cc56c2ab990299f95f1fbff03465b9022
7
- data.tar.gz: 7693f227da2d82c5389074f4e8375ed011ffe2cd77c7e4517480ec38308500c15ccab679a2a326af31ac3ca08f77cc084b7031ebb3d0e291e18745c9335e97ab
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 (like RabbitMQ), allowing easy broker swapping and clean synchronous testing with an in-memory adapter.
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 to decouple Rails from physical messaging queues.
12
- - **RabbitMQ Adapter**: Robust, persistent connection wrapper using the `bunny` gem.
13
- - **InMemory Adapter**: Synchronous local queue simulation for fast TDD testing (no inline external I/O stubs required).
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 the Adapter based on Environment
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 real RabbitMQ broker
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
- # 2. Instantiate the Client by Injecting the Adapter
54
- SPOT_BROKER = SharedBroker::Client.new(adapter: BROKER_ADAPTER)
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
- # 3. Initialize Telemetry (OpenTelemetry)
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 simple events by passing the topic name and a structured payload (must be a `Hash`):
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
- name: "Eiffel Tower",
71
- latitude: 48.8584,
72
- longitude: 2.2945
101
+ email: "test@example.com"
73
102
  }
74
103
 
75
- SPOT_BROKER.publish("spot.created", event_data)
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("spot.created", "my_consumption_queue") do |payload|
83
- puts "Event successfully consumed! ID: #{payload[:id]}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SharedBroker
4
- VERSION = "0.1.0"
4
+ VERSION = "1.1.0"
5
5
  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
- def initialize(adapter:)
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
- @adapter.publish(topic, message)
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, &block)
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: 0.1.0
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/onkai/shared_broker
147
+ homepage: https://github.com/wesleyskap/shared_broker
125
148
  licenses:
126
149
  - MIT
127
150
  metadata:
128
- source_code_uri: https://github.com/onkai/shared_broker
129
- changelog_uri: https://github.com/onkai/shared_broker/blob/main/CHANGELOG.md
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: