bug_bunny 4.6.1 → 4.8.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
  3. data/.claude/commands/gem-ai-setup.md +174 -0
  4. data/.claude/commands/pr.md +53 -0
  5. data/.claude/commands/release.md +52 -0
  6. data/.claude/commands/rubocop.md +22 -0
  7. data/.claude/commands/service-ai-setup.md +168 -0
  8. data/.claude/commands/test.md +28 -0
  9. data/.claude/commands/yard.md +46 -0
  10. data/CHANGELOG.md +50 -15
  11. data/CLAUDE.md +240 -0
  12. data/README.md +154 -221
  13. data/Rakefile +19 -3
  14. data/docs/_index.md +50 -0
  15. data/docs/ai/_index.md +56 -0
  16. data/docs/ai/antipatterns.md +166 -0
  17. data/docs/ai/api.md +251 -0
  18. data/docs/ai/architecture.md +92 -0
  19. data/docs/ai/errors.md +158 -0
  20. data/docs/ai/faq_external.md +133 -0
  21. data/docs/ai/faq_internal.md +86 -0
  22. data/docs/ai/glossary.md +45 -0
  23. data/docs/concepts.md +140 -0
  24. data/docs/howto/controller.md +194 -0
  25. data/docs/howto/middleware_client.md +119 -0
  26. data/docs/howto/middleware_consumer.md +127 -0
  27. data/docs/howto/rails.md +214 -0
  28. data/docs/howto/resource.md +200 -0
  29. data/docs/howto/routing.md +133 -0
  30. data/docs/howto/testing.md +259 -0
  31. data/docs/howto/tracing.md +119 -0
  32. data/lib/bug_bunny/client.rb +45 -21
  33. data/lib/bug_bunny/configuration.rb +63 -0
  34. data/lib/bug_bunny/consumer.rb +51 -37
  35. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  36. data/lib/bug_bunny/controller.rb +39 -18
  37. data/lib/bug_bunny/exception.rb +5 -1
  38. data/lib/bug_bunny/middleware/raise_error.rb +3 -3
  39. data/lib/bug_bunny/observability.rb +28 -6
  40. data/lib/bug_bunny/producer.rb +11 -13
  41. data/lib/bug_bunny/railtie.rb +8 -7
  42. data/lib/bug_bunny/request.rb +3 -11
  43. data/lib/bug_bunny/resource.rb +81 -41
  44. data/lib/bug_bunny/routing/route.rb +6 -1
  45. data/lib/bug_bunny/routing/route_set.rb +60 -22
  46. data/lib/bug_bunny/session.rb +18 -11
  47. data/lib/bug_bunny/version.rb +1 -1
  48. data/lib/bug_bunny.rb +4 -2
  49. data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
  50. data/lib/tasks/bug_bunny.rake +50 -0
  51. data/plan_test.txt +63 -0
  52. data/skills-lock.json +10 -0
  53. data/spec/integration/client_spec.rb +117 -0
  54. data/spec/integration/consumer_middleware_spec.rb +86 -0
  55. data/spec/integration/controller_spec.rb +140 -0
  56. data/spec/integration/error_handling_spec.rb +57 -0
  57. data/spec/integration/infrastructure_spec.rb +52 -0
  58. data/spec/integration/resource_spec.rb +113 -0
  59. data/spec/spec_helper.rb +70 -0
  60. data/spec/support/bunny_mocks.rb +18 -0
  61. data/spec/support/integration_helper.rb +87 -0
  62. data/spec/unit/client_session_pool_spec.rb +159 -0
  63. data/spec/unit/configuration_spec.rb +164 -0
  64. data/spec/unit/consumer_middleware_spec.rb +129 -0
  65. data/spec/unit/consumer_spec.rb +90 -0
  66. data/spec/unit/controller_after_action_spec.rb +155 -0
  67. data/spec/unit/observability_spec.rb +167 -0
  68. data/spec/unit/resource_attributes_spec.rb +69 -0
  69. data/spec/unit/session_spec.rb +98 -0
  70. metadata +50 -3
  71. data/sig/bug_bunny.rbs +0 -4
@@ -0,0 +1,133 @@
1
+ # Routing
2
+
3
+ Routes declare how incoming AMQP messages map to controllers and actions. They are evaluated on every message received by the Consumer.
4
+
5
+ ## Drawing Routes
6
+
7
+ Define routes in an initializer (or any file loaded at boot):
8
+
9
+ ```ruby
10
+ BugBunny.routes.draw do
11
+ # routes here
12
+ end
13
+ ```
14
+
15
+ Call `draw` only once. Multiple calls replace the previous route set.
16
+
17
+ ---
18
+
19
+ ## HTTP Verbs
20
+
21
+ ```ruby
22
+ BugBunny.routes.draw do
23
+ get 'status', to: 'health#show'
24
+ post 'events', to: 'events#create'
25
+ put 'users/:id', to: 'users#update'
26
+ delete 'users/:id', to: 'users#destroy'
27
+ end
28
+ ```
29
+
30
+ The verb is set by the producer via the `x-http-method` AMQP header. `BugBunny::Resource` and `BugBunny::Client` set it automatically.
31
+
32
+ Dynamic segments (`:id`) are extracted from the path and available in `params` inside the controller.
33
+
34
+ ---
35
+
36
+ ## Resources Macro
37
+
38
+ `resources` generates the standard seven CRUD routes in one line:
39
+
40
+ ```ruby
41
+ resources :users
42
+ ```
43
+
44
+ Generates:
45
+
46
+ | Verb | Path | Action |
47
+ |--------|---------------|-----------|
48
+ | GET | users | index |
49
+ | POST | users | create |
50
+ | GET | users/:id | show |
51
+ | PUT | users/:id | update |
52
+ | DELETE | users/:id | destroy |
53
+
54
+ ### Filtering actions
55
+
56
+ ```ruby
57
+ resources :orders, only: [:index, :show, :create]
58
+ resources :logs, except: [:update, :destroy]
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Member and Collection Routes
64
+
65
+ ```ruby
66
+ resources :nodes do
67
+ member do
68
+ put :drain # PUT nodes/:id/drain → NodesController#drain
69
+ post :reboot # POST nodes/:id/reboot → NodesController#reboot
70
+ end
71
+
72
+ collection do
73
+ post :rebalance # POST nodes/rebalance → NodesController#rebalance
74
+ get :summary # GET nodes/summary → NodesController#summary
75
+ end
76
+ end
77
+ ```
78
+
79
+ Member routes receive `params[:id]` automatically. Collection routes do not.
80
+
81
+ ---
82
+
83
+ ## Namespace Blocks
84
+
85
+ Group routes under a controller namespace. Namespaces stack: nested `namespace` blocks accumulate with `::`.
86
+
87
+ ```ruby
88
+ BugBunny.routes.draw do
89
+ namespace :api do
90
+ namespace :v1 do
91
+ resources :metrics # → Api::V1::MetricsController
92
+ resources :alerts # → Api::V1::AlertsController
93
+ end
94
+
95
+ resources :health # → Api::HealthController
96
+ end
97
+
98
+ resources :nodes # → BugBunny::Controllers::NodesController (global namespace)
99
+ end
100
+ ```
101
+
102
+ The namespace in the route takes precedence over `config.controller_namespace`. Routes without a namespace block use the global controller namespace.
103
+
104
+ ---
105
+
106
+ ## Nested Resources
107
+
108
+ ```ruby
109
+ resources :clusters do
110
+ resources :nodes do # → nodes/:id nested under clusters/:cluster_id
111
+ member { put :drain }
112
+ end
113
+ end
114
+ ```
115
+
116
+ Nested resource routes inject all parent IDs into params. Example: `PUT clusters/c1/nodes/n2/drain` → `params[:cluster_id] = 'c1'`, `params[:id] = 'n2'`.
117
+
118
+ ---
119
+
120
+ ## Inspecting Routes
121
+
122
+ ```ruby
123
+ BugBunny.routes.recognize('GET', 'nodes/123')
124
+ # => { controller: 'nodes', action: 'show', params: { 'id' => '123' }, namespace: nil }
125
+
126
+ BugBunny.routes.recognize('POST', 'api/v1/metrics')
127
+ # => { controller: 'metrics', action: 'create', params: {}, namespace: 'Api::V1' }
128
+
129
+ BugBunny.routes.recognize('GET', 'unknown/path')
130
+ # => nil
131
+ ```
132
+
133
+ Useful in tests to verify route definitions without sending real messages.
@@ -0,0 +1,259 @@
1
+ # Testing
2
+
3
+ BugBunny applications can be tested at two levels: **unit tests** (with Bunny doubles) and **integration tests** (with a full mocked AMQP stack). No real RabbitMQ server is required.
4
+
5
+ ## Setup
6
+
7
+ ```ruby
8
+ # spec/spec_helper.rb
9
+ require 'bug_bunny'
10
+ require 'rspec'
11
+
12
+ Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
13
+
14
+ RSpec.configure do |config|
15
+ config.before(:each) do
16
+ # Reset global state between tests
17
+ BugBunny.instance_variable_set(:@consumer_middlewares, nil)
18
+ BugBunny.instance_variable_set(:@routes, nil)
19
+ end
20
+ end
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Bunny Doubles
26
+
27
+ Create lightweight doubles for Bunny objects so tests never touch the network:
28
+
29
+ ```ruby
30
+ # spec/support/bunny_mocks.rb
31
+
32
+ def build_bunny_channel(opts = {})
33
+ channel = instance_double(Bunny::Channel)
34
+ allow(channel).to receive(:open?).and_return(true)
35
+ allow(channel).to receive(:prefetch)
36
+ allow(channel).to receive(:confirm_select)
37
+ allow(channel).to receive(:ack)
38
+ allow(channel).to receive(:reject)
39
+ allow(channel).to receive(:close)
40
+ allow(channel).to receive(:default_exchange).and_return(build_bunny_exchange)
41
+ allow(channel).to receive(:topic).and_return(build_bunny_exchange)
42
+ allow(channel).to receive(:direct).and_return(build_bunny_exchange)
43
+ channel
44
+ end
45
+
46
+ def build_bunny_connection(opts = {})
47
+ conn = instance_double(Bunny::Session)
48
+ allow(conn).to receive(:open?).and_return(true)
49
+ allow(conn).to receive(:start)
50
+ allow(conn).to receive(:create_channel).and_return(build_bunny_channel)
51
+ conn
52
+ end
53
+
54
+ def build_bunny_exchange
55
+ exchange = instance_double(Bunny::Exchange)
56
+ allow(exchange).to receive(:publish)
57
+ exchange
58
+ end
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Unit Testing Controllers
64
+
65
+ Test controller actions directly via `Controller.call`, bypassing AMQP entirely:
66
+
67
+ ```ruby
68
+ RSpec.describe NodesController do
69
+ let(:node) { Node.new(id: '42', name: 'web-01', status: 'active') }
70
+
71
+ describe '#show' do
72
+ it 'returns the node as JSON' do
73
+ allow(Node).to receive(:find).with('42').and_return(node)
74
+
75
+ response = NodesController.call(
76
+ headers: { type: 'nodes/42', 'x-http-method' => 'GET' },
77
+ body: ''
78
+ )
79
+
80
+ expect(response[:status]).to eq(200)
81
+ expect(response[:body][:name]).to eq('web-01')
82
+ end
83
+ end
84
+
85
+ describe '#show with missing node' do
86
+ it 'returns 404' do
87
+ allow(Node).to receive(:find).with('99').and_return(nil)
88
+
89
+ response = NodesController.call(
90
+ headers: { type: 'nodes/99', 'x-http-method' => 'GET' },
91
+ body: ''
92
+ )
93
+
94
+ expect(response[:status]).to eq(404)
95
+ end
96
+ end
97
+ end
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Unit Testing before_action / after_action
103
+
104
+ ```ruby
105
+ RSpec.describe NodesController do
106
+ describe 'before_action :authenticate!' do
107
+ it 'returns 401 when token is missing' do
108
+ response = NodesController.call(
109
+ headers: { type: 'nodes', 'x-http-method' => 'GET' },
110
+ body: ''
111
+ # no X-Service-Token header
112
+ )
113
+
114
+ expect(response[:status]).to eq(401)
115
+ end
116
+ end
117
+
118
+ describe 'after_action :emit_audit_event' do
119
+ it 'emits an audit event after create' do
120
+ expect(AuditLog).to receive(:record).with(hash_including(action: 'create'))
121
+
122
+ NodesController.call(
123
+ headers: { type: 'nodes', 'x-http-method' => 'POST', 'X-Service-Token' => 'valid' },
124
+ body: '{"node":{"name":"web-01","status":"pending"}}'
125
+ )
126
+ end
127
+ end
128
+ end
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Unit Testing Consumer Middleware
134
+
135
+ ```ruby
136
+ RSpec.describe TracingMiddleware do
137
+ subject(:middleware) { described_class.new(terminal) }
138
+
139
+ let(:terminal) { ->(di, props, body) { :processed } }
140
+ let(:delivery_info) { instance_double(Bunny::DeliveryInfo, routing_key: 'nodes') }
141
+ let(:properties) do
142
+ instance_double(Bunny::MessageProperties,
143
+ headers: { 'X-Trace-Id' => 'trace-123' },
144
+ correlation_id: 'corr-456'
145
+ )
146
+ end
147
+
148
+ it 'extracts the trace header and sets context' do
149
+ expect(MyTracer).to receive(:with_trace).with('trace-123').and_yield
150
+
151
+ middleware.call(delivery_info, properties, '{}')
152
+ end
153
+
154
+ it 'generates a new trace id when header is absent' do
155
+ allow(properties).to receive(:headers).and_return({})
156
+ expect(MyTracer).to receive(:with_trace).with(be_a(String)).and_yield
157
+
158
+ middleware.call(delivery_info, properties, '{}')
159
+ end
160
+ end
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Integration Testing with a Mock Consumer
166
+
167
+ For tests that exercise the full routing + controller stack, use an in-process integration helper:
168
+
169
+ ```ruby
170
+ # spec/support/integration_helper.rb
171
+
172
+ module IntegrationHelper
173
+ def process_message(method:, path:, body: '', headers: {})
174
+ delivery_info = instance_double(Bunny::DeliveryInfo,
175
+ delivery_tag: 1,
176
+ routing_key: 'test'
177
+ )
178
+
179
+ properties = instance_double(Bunny::MessageProperties,
180
+ type: path,
181
+ reply_to: nil,
182
+ correlation_id: SecureRandom.uuid,
183
+ headers: { 'x-http-method' => method.to_s.upcase }.merge(headers)
184
+ )
185
+
186
+ consumer = BugBunny::Consumer.new
187
+ # Allow channel operations on the mock session
188
+ allow(consumer.session.channel).to receive(:ack)
189
+ allow(consumer.session.channel).to receive(:reject)
190
+ allow(consumer.session.channel).to receive(:default_exchange).and_return(build_bunny_exchange)
191
+
192
+ consumer.send(:process_message, delivery_info, properties, body.to_json)
193
+ end
194
+ end
195
+
196
+ RSpec.configure do |config|
197
+ config.include IntegrationHelper, type: :integration
198
+ end
199
+ ```
200
+
201
+ ```ruby
202
+ # spec/integration/nodes_spec.rb, type: :integration
203
+ RSpec.describe 'Nodes API', type: :integration do
204
+ before do
205
+ BugBunny.routes.draw { resources :nodes }
206
+ end
207
+
208
+ it 'routes GET nodes/:id to NodesController#show' do
209
+ allow(Node).to receive(:find).with('42').and_return(Node.new(id: 42, name: 'web-01'))
210
+
211
+ process_message(method: :get, path: 'nodes/42')
212
+
213
+ expect(Node).to have_received(:find).with('42')
214
+ end
215
+
216
+ it 'returns 404 for unregistered routes' do
217
+ response = process_message(method: :get, path: 'unknown/path')
218
+ # response is captured via the consumer's handle_fatal_error path
219
+ end
220
+ end
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Unit Testing Configuration Validation
226
+
227
+ ```ruby
228
+ RSpec.describe BugBunny::Configuration do
229
+ describe '#validate!' do
230
+ it 'raises ConfigurationError when host is blank' do
231
+ config = described_class.new
232
+ config.host = ''
233
+
234
+ expect { config.validate! }.to raise_error(BugBunny::ConfigurationError, /host is required/)
235
+ end
236
+
237
+ it 'raises ConfigurationError when port is out of range' do
238
+ config = described_class.new
239
+ config.host = 'localhost'
240
+ config.port = 99_999
241
+
242
+ expect { config.validate! }.to raise_error(BugBunny::ConfigurationError, /port must be in/)
243
+ end
244
+ end
245
+ end
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Running Tests
251
+
252
+ ```bash
253
+ source /opt/homebrew/opt/chruby/share/chruby/chruby.sh && chruby ruby-3.3.8
254
+
255
+ bundle exec rspec # all tests
256
+ bundle exec rspec spec/unit/ # unit tests only
257
+ bundle exec rspec spec/integration/ # integration tests only
258
+ bundle exec rspec spec/unit/consumer_spec.rb # single file
259
+ ```
@@ -0,0 +1,119 @@
1
+ # Distributed Tracing
2
+
3
+ BugBunny propagates trace context through the full RPC cycle, from the producer to the consumer and back. The mechanism is tracer-agnostic: BugBunny provides hooks and you supply the tracer-specific logic.
4
+
5
+ ## What BugBunny Propagates by Default
6
+
7
+ The `correlation_id` AMQP property travels automatically from producer to consumer on every request. The Consumer wraps the entire execution in a `logger.tagged(correlation_id)` block when the logger supports tagged logging (Rails' `ActiveSupport::TaggedLogging`).
8
+
9
+ This alone connects producer and consumer log lines without any configuration.
10
+
11
+ ---
12
+
13
+ ## Full Bidirectional Propagation
14
+
15
+ For distributed tracing systems (OpenTelemetry, AWS X-Ray, Datadog APM, etc.) you need to:
16
+
17
+ 1. **Inject** the current trace header into outgoing requests (producer side).
18
+ 2. **Extract** the trace header from incoming messages (consumer side) — done via Consumer Middleware.
19
+ 3. **Re-inject** the updated trace header into the RPC reply (consumer side).
20
+ 4. **Hydrate** the trace context from the reply headers back in the calling thread (producer side).
21
+
22
+ Steps 3 and 4 handle the case where the consumer creates a child span — the parent needs to know about it.
23
+
24
+ ---
25
+
26
+ ## Configuration Hooks
27
+
28
+ ### `rpc_reply_headers`
29
+
30
+ A `Proc` called in the consumer thread just before sending the RPC reply. Its return value is merged into the reply AMQP headers.
31
+
32
+ ```ruby
33
+ BugBunny.configure do |config|
34
+ config.rpc_reply_headers = -> {
35
+ { 'X-Trace-Header' => MyTracer.outgoing_header }
36
+ }
37
+ end
38
+ ```
39
+
40
+ Zero overhead when not set.
41
+
42
+ ### `on_rpc_reply`
43
+
44
+ A `Proc` called in the producer thread after the RPC reply arrives, with the reply headers as argument. Use it to hydrate the trace context in the calling thread.
45
+
46
+ ```ruby
47
+ BugBunny.configure do |config|
48
+ config.on_rpc_reply = ->(headers) {
49
+ MyTracer.hydrate(headers['X-Trace-Header'])
50
+ }
51
+ end
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Full Example (tracer-agnostic)
57
+
58
+ ```ruby
59
+ # config/initializers/bug_bunny.rb
60
+
61
+ BugBunny.configure do |config|
62
+ # ... connection config ...
63
+
64
+ # Step 3: inject updated trace header into RPC reply
65
+ config.rpc_reply_headers = -> {
66
+ { 'X-Trace-Header' => MyTracer.generate_outgoing_header }
67
+ }
68
+
69
+ # Step 4: hydrate trace context in the producer thread after reply
70
+ config.on_rpc_reply = ->(headers) {
71
+ MyTracer.hydrate_from_header(headers['X-Trace-Header'])
72
+ }
73
+ end
74
+
75
+ # Step 1: inject trace header into outgoing requests (client middleware)
76
+ class TraceInjectionMiddleware < BugBunny::Middleware::Base
77
+ def call(request)
78
+ request.headers['X-Trace-Header'] = MyTracer.generate_outgoing_header
79
+ app.call(request)
80
+ end
81
+ end
82
+
83
+ # Step 2: extract trace header from incoming messages (consumer middleware)
84
+ class TraceExtractionMiddleware < BugBunny::ConsumerMiddleware::Base
85
+ def call(delivery_info, properties, body)
86
+ incoming_header = properties.headers&.dig('X-Trace-Header')
87
+ MyTracer.with_trace_from_header(incoming_header) do
88
+ @app.call(delivery_info, properties, body)
89
+ end
90
+ end
91
+ end
92
+
93
+ # Register both
94
+ BugBunny::Resource.client_middleware { |s| s.use TraceInjectionMiddleware }
95
+ BugBunny.consumer_middlewares.use TraceExtractionMiddleware
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Fire-and-Forget Tracing
101
+
102
+ For `:publish` (fire-and-forget) calls, there is no reply, so `on_rpc_reply` and `rpc_reply_headers` do not apply. Use only the client middleware to inject the outgoing trace header:
103
+
104
+ ```ruby
105
+ class TraceInjectionMiddleware < BugBunny::Middleware::Base
106
+ def call(request)
107
+ request.headers['X-Trace-Header'] = MyTracer.generate_outgoing_header
108
+ app.call(request)
109
+ end
110
+ end
111
+ ```
112
+
113
+ The consumer middleware extracts it on the other side regardless of whether the call was RPC or fire-and-forget.
114
+
115
+ ---
116
+
117
+ ## correlation_id
118
+
119
+ BugBunny sets `correlation_id` automatically on every RPC request (used internally to match replies). It is also forwarded as a log tag. If your tracer uses a separate header (e.g., `traceparent`), use the `X-Trace-Header` pattern above. If you want to use `correlation_id` as the trace ID, read it from `properties.correlation_id` in your consumer middleware.
@@ -31,6 +31,7 @@ module BugBunny
31
31
  # @raise [ArgumentError] Si no se proporciona un `pool`.
32
32
  def initialize(pool:)
33
33
  raise ArgumentError, "BugBunny::Client requiere un 'pool:'" if pool.nil?
34
+
34
35
  @pool = pool
35
36
  @stack = BugBunny::Middleware::Stack.new
36
37
  @delivery_mode = :rpc
@@ -96,7 +97,7 @@ module BugBunny
96
97
  req = BugBunny::Request.new(url)
97
98
 
98
99
  # 2. Syntactic Sugar: Mapeo de argumentos a atributos del Request
99
- req.delivery_mode = delivery_mode # Default del cliente
100
+ req.delivery_mode = delivery_mode # Default del cliente
100
101
  req.delivery_mode = args[:delivery_mode] if args[:delivery_mode]
101
102
  req.method = args[:method] if args[:method]
102
103
  req.body = args[:body] if args[:body]
@@ -109,33 +110,56 @@ module BugBunny
109
110
  req.exchange_options = args[:exchange_options] if args[:exchange_options]
110
111
  req.queue_options = args[:queue_options] if args[:queue_options]
111
112
 
112
- req.params = args[:params] if args[:params]
113
- req.headers.merge!(args[:headers]) if args[:headers]
113
+ req.params = args[:params] if args[:params]
114
+ req.headers.merge!(args[:headers]) if args[:headers]
114
115
 
115
116
  # 3. Configuración del usuario (bloque específico por request)
116
117
  yield req if block_given?
117
118
 
118
119
  # 4. Ejecución dentro del Pool
120
+ # Session y Producer se reutilizan por slot de conexión (ver #session_for / #producer_for).
119
121
  @pool.with do |conn|
120
- session = BugBunny::Session.new(conn)
121
- producer = BugBunny::Producer.new(session)
122
-
123
- begin
124
- # Mapeo de delivery_mode al método del productor (:rpc o :fire)
125
- # :publish se mapea a :fire por consistencia interna.
126
- method_name = req.delivery_mode == :publish ? :fire : :rpc
127
-
128
- # Onion Architecture: La acción final es llamar al Producer real.
129
- final_action = ->(env) { producer.send(method_name, env) }
130
-
131
- # Construimos y ejecutamos la cadena de middlewares
132
- app = @stack.build(final_action)
133
- app.call(req)
134
- ensure
135
- # Aseguramos el cierre del canal pero mantenemos la conexión del pool
136
- session.close
137
- end
122
+ session = session_for(conn)
123
+ producer = producer_for(conn, session)
124
+
125
+ # Mapeo de delivery_mode al método del productor (:rpc o :fire)
126
+ method_name = req.delivery_mode == :publish ? :fire : :rpc
127
+
128
+ # Onion Architecture: La acción final es llamar al Producer real.
129
+ final_action = ->(env) { producer.send(method_name, env) }
130
+
131
+ # Construimos y ejecutamos la cadena de middlewares
132
+ app = @stack.build(final_action)
133
+ app.call(req)
138
134
  end
139
135
  end
136
+
137
+ # Recupera o crea la Session asociada al slot de conexión dado.
138
+ #
139
+ # La Session (y su canal AMQP) se almacena como ivar en el objeto `conn`.
140
+ # Thread-safe sin mutex adicional: ConnectionPool garantiza que cada `conn`
141
+ # es usado por un único thread a la vez.
142
+ #
143
+ # @param conn [Bunny::Session] Conexión activa del pool.
144
+ # @return [BugBunny::Session]
145
+ def session_for(conn)
146
+ conn.instance_variable_get(:@_bug_bunny_session) ||
147
+ conn.instance_variable_set(:@_bug_bunny_session, BugBunny::Session.new(conn))
148
+ end
149
+
150
+ # Recupera o crea el Producer asociado al slot de conexión dado.
151
+ #
152
+ # El Producer debe cachearse junto con la Session porque registra un
153
+ # `basic_consume` sobre el canal para escuchar replies RPC. Si se creara
154
+ # un Producer nuevo por request (con el canal reutilizado), se intentaría
155
+ # registrar un segundo consumidor sobre el mismo canal, causando un error AMQP.
156
+ #
157
+ # @param conn [Bunny::Session] Conexión activa del pool.
158
+ # @param session [BugBunny::Session] Session ya resuelta para `conn`.
159
+ # @return [BugBunny::Producer]
160
+ def producer_for(conn, session)
161
+ conn.instance_variable_get(:@_bug_bunny_producer) ||
162
+ conn.instance_variable_set(:@_bug_bunny_producer, BugBunny::Producer.new(session))
163
+ end
140
164
  end
141
165
  end
@@ -14,6 +14,28 @@ module BugBunny
14
14
  # config.health_check_file = '/tmp/bug_bunny_health'
15
15
  # end
16
16
  class Configuration
17
+ # Reglas de validación por atributo.
18
+ # Solo cubre los atributos de conexión y timeout — los demás (logger, procs, hashes)
19
+ # son tipos arbitrarios que no tienen sentido validar de forma genérica.
20
+ #
21
+ # Claves soportadas:
22
+ # - `:type` — clase que debe responder `is_a?`
23
+ # - `:required` — si `true`, nil o string vacío lanzan ConfigurationError
24
+ # - `:range` — rango válido de valores (solo para Integer)
25
+ VALIDATIONS = {
26
+ host: { type: String, required: true },
27
+ port: { type: Integer, required: true, range: 1..65_535 },
28
+ username: { type: String, required: true },
29
+ password: { type: String, required: true },
30
+ vhost: { type: String, required: true },
31
+ heartbeat: { type: Integer, range: 0..3_600 },
32
+ connection_timeout: { type: Integer, range: 1..300 },
33
+ read_timeout: { type: Integer, range: 1..300 },
34
+ write_timeout: { type: Integer, range: 1..300 },
35
+ rpc_timeout: { type: Integer, range: 1..3_600 },
36
+ channel_prefetch: { type: Integer, range: 1..10_000 }
37
+ }.freeze
38
+
17
39
  # @return [String] Host o IP del servidor RabbitMQ (ej: 'localhost').
18
40
  attr_accessor :host
19
41
 
@@ -163,5 +185,46 @@ module BugBunny
163
185
  def url
164
186
  "amqp://#{username}:#{password}@#{host}:#{port}/#{vhost}"
165
187
  end
188
+
189
+ # Valida todos los atributos definidos en {VALIDATIONS}.
190
+ # Se invoca automáticamente al final de {BugBunny.configure}.
191
+ #
192
+ # @raise [BugBunny::ConfigurationError] Si algún atributo es inválido.
193
+ # @return [void]
194
+ def validate!
195
+ VALIDATIONS.each do |attr, rules|
196
+ value = send(attr)
197
+ validate_required!(attr, value, rules)
198
+ next if value.nil?
199
+
200
+ validate_type!(attr, value, rules)
201
+ validate_range!(attr, value, rules)
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ def validate_required!(attr, value, rules)
208
+ return unless rules[:required]
209
+ return unless value.nil? || (value.is_a?(String) && value.empty?)
210
+
211
+ raise BugBunny::ConfigurationError, "#{attr} is required"
212
+ end
213
+
214
+ def validate_type!(attr, value, rules)
215
+ return unless rules[:type]
216
+ return if value.is_a?(rules[:type])
217
+
218
+ raise BugBunny::ConfigurationError,
219
+ "#{attr} must be a #{rules[:type]}, got #{value.class}"
220
+ end
221
+
222
+ def validate_range!(attr, value, rules)
223
+ return unless rules[:range]
224
+ return if rules[:range].cover?(value)
225
+
226
+ raise BugBunny::ConfigurationError,
227
+ "#{attr} must be in #{rules[:range]}, got #{value.inspect}"
228
+ end
166
229
  end
167
230
  end