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
+ ## FAQ — External (Integrator / Consumer of BugBunny)
2
+
3
+ ### How do I set up BugBunny in a Rails app?
4
+
5
+ Add to Gemfile: `gem 'bug_bunny'` and `gem 'connection_pool'`. Run `rails generate bug_bunny:install`. Edit `config/initializers/bug_bunny.rb` with your RabbitMQ credentials. Create a pool: `MY_POOL = ConnectionPool.new(size: 5) { BugBunny.create_connection }`. Assign it: `BugBunny::Resource.connection_pool = MY_POOL`. See `docs/howto/rails.md` for the complete setup.
6
+
7
+ ---
8
+
9
+ ### How do I make an RPC call (blocking request)?
10
+
11
+ Use `Resource.find` / `Resource.where` / `Resource.create` — they all do RPC internally. For lower-level control: `client.request('users/1', method: :get, exchange: 'users_exchange', routing_key: 'users')`. The call blocks until the remote service replies or `rpc_timeout` expires.
12
+
13
+ ---
14
+
15
+ ### How do I publish without waiting for a reply?
16
+
17
+ Use `client.publish('events', method: :post, exchange: 'events_exchange', routing_key: 'events', body: { ... })`. Fire-and-forget — does not block. No RPC timeout applies.
18
+
19
+ ---
20
+
21
+ ### What happens when `find` returns nil vs raises?
22
+
23
+ `Resource.find` returns `nil` on 404 (does not raise). `Resource.where` returns `[]` on 404. Both raise `BugBunny::RequestTimeout` if the remote service does not reply within `rpc_timeout`. They raise `BugBunny::ServerError` on 5xx responses.
24
+
25
+ ---
26
+
27
+ ### How do I handle validation errors from the remote service?
28
+
29
+ `resource.save` returns `false` on 422 and loads the remote errors into `resource.errors`. Check `resource.valid?` then `resource.errors.full_messages`. The remote service must render `{ errors: { field: ['message'] } }` for the errors to be auto-loaded.
30
+
31
+ ---
32
+
33
+ ### How do I use `.with` to override the exchange per call?
34
+
35
+ ```ruby
36
+ # Block form (preferred — thread-safe, restores after block)
37
+ Order.with(exchange: 'priority_exchange', routing_key: 'priority') do
38
+ Order.find(id)
39
+ end
40
+
41
+ # Single-call proxy
42
+ Order.with(exchange: 'priority_exchange').find(id)
43
+ # ScopeProxy is single-use — calling a second method raises BugBunny::Error
44
+ ```
45
+
46
+ ---
47
+
48
+ ### How do I define a Resource with typed attributes?
49
+
50
+ ```ruby
51
+ class User < BugBunny::Resource
52
+ self.exchange = 'users_exchange'
53
+
54
+ attribute :id, :integer
55
+ attribute :name, :string
56
+ attribute :email, :string
57
+
58
+ validates :name, presence: true
59
+ end
60
+ ```
61
+
62
+ Typed attributes get ActiveModel coercion and dirty tracking. Attributes not declared with `attribute` are handled dynamically via `method_missing` and tracked in `@extra_attributes`.
63
+
64
+ ---
65
+
66
+ ### How do I add client-side middleware to a Resource?
67
+
68
+ ```ruby
69
+ class Order < BugBunny::Resource
70
+ client_middleware do |stack|
71
+ stack.use MyRetryMiddleware
72
+ end
73
+ end
74
+ ```
75
+
76
+ Middlewares are inherited by subclasses and applied in registration order (first = outermost). `RaiseError` and `JsonResponse` are always added as the innermost middlewares by `Resource` — do not add them manually.
77
+
78
+ ---
79
+
80
+ ### How do I run the Consumer in a Rails app?
81
+
82
+ The Consumer is a blocking loop. Run it in a separate process, not inside Puma:
83
+
84
+ ```ruby
85
+ # lib/tasks/rabbit.rake
86
+ task consumer: :environment do
87
+ conn = BugBunny.create_connection
88
+ consumer = BugBunny::Consumer.new(conn)
89
+ trap('TERM') { consumer.shutdown; exit }
90
+ trap('INT') { consumer.shutdown; exit }
91
+ consumer.subscribe(
92
+ queue_name: 'my_queue', exchange_name: 'my_exchange', routing_key: 'my_key'
93
+ )
94
+ end
95
+ ```
96
+
97
+ ---
98
+
99
+ ### How do I write a Controller?
100
+
101
+ Subclass `BugBunny::Controller`. Place it in the namespace from `config.controller_namespace` (default: `BugBunny::Controllers`). Implement action methods (`index`, `show`, `create`, `update`, `destroy` or custom). Call `render(status:, json:)` to respond. The Consumer routes messages to `YourController.call(headers:, body:)`.
102
+
103
+ ---
104
+
105
+ ### How do I propagate trace context through RabbitMQ?
106
+
107
+ On the consumer side, inject headers into replies:
108
+ ```ruby
109
+ config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.current } }
110
+ ```
111
+ On the producer side, hydrate context from the reply:
112
+ ```ruby
113
+ config.on_rpc_reply = ->(headers) { Tracer.hydrate(headers['X-Trace-Id']) }
114
+ ```
115
+ For consumer-side middleware (propagating from incoming request), use `ConsumerMiddleware`.
116
+
117
+ ---
118
+
119
+ ### How do I configure health checks for Kubernetes?
120
+
121
+ Set `config.health_check_file = '/app/tmp/bug_bunny_health'`. BugBunny touches that file every `health_check_interval` seconds (default: 60) after verifying the RabbitMQ connection. Add a `livenessProbe` in your Kubernetes manifest checking for that file's existence.
122
+
123
+ ---
124
+
125
+ ### What is the default RPC timeout and how do I change it?
126
+
127
+ Default: 10 seconds. Override globally: `config.rpc_timeout = 30`. Override per request: `client.request('users/1', method: :get, exchange: ..., timeout: 5)`. When exceeded, `BugBunny::RequestTimeout` is raised.
128
+
129
+ ---
130
+
131
+ ### Do I need to declare exchanges and queues manually?
132
+
133
+ No. BugBunny declares them automatically when `subscribe` (consumer) or the first RPC call (producer) is made. Use `config.exchange_options` and `config.queue_options` for global defaults (e.g., `{ durable: true }`). Override per resource with `Resource.exchange_options=` or per call with `.with(exchange_options:)`.
@@ -0,0 +1,86 @@
1
+ ## FAQ — Internal (Maintainer / Developer of BugBunny)
2
+
3
+ ### Why is Producer cached per connection slot instead of instantiated per request?
4
+
5
+ `Producer#initialize` calls `channel.basic_consume` on the `amq.rabbitmq.reply-to` pseudo-queue to listen for RPC replies. Creating a second `basic_consume` on the same channel raises an AMQP error. Therefore, one Producer per channel (= per connection slot) is mandatory. The cache is stored as `@_bug_bunny_producer` ivar on the Bunny connection object, which is safe because `ConnectionPool` guarantees each slot is used by one thread at a time.
6
+
7
+ ---
8
+
9
+ ### Why is Session cached per connection slot?
10
+
11
+ Same reason as Producer — and the Session wraps the channel. Recreating a Session would create a new channel and invalidate the cached exchange/queue objects. Caching is stored as `@_bug_bunny_session` on the Bunny connection object.
12
+
13
+ ---
14
+
15
+ ### How does the Session handle thread safety for exchange/queue declarations?
16
+
17
+ Session uses a double-checked locking pattern with a `Mutex` for its caches (`@exchange_cache`, `@queue_cache`). The read path (fast path) checks without locking; the write path acquires the mutex and checks again before writing. This avoids redundant AMQP declarations without making every read a mutex acquisition.
18
+
19
+ ---
20
+
21
+ ### Why does Resource have two dirty tracking mechanisms?
22
+
23
+ ActiveModel::Dirty only tracks attributes declared with `attribute :name, :type`. BugBunny Resources allow dynamic attributes (unknown at class definition time) stored in `@extra_attributes`. These are tracked manually via `@dynamic_changes` (a `Set`). The `changed?` and `changed` methods merge both to produce a unified change list.
24
+
25
+ ---
26
+
27
+ ### How does the 3-level config cascade work in Resource?
28
+
29
+ `resolve_config(key, instance_var)` checks in order:
30
+ 1. `Thread.current["bb_#{object_id}_#{key}"]` — set by `.with(...)`.
31
+ 2. `self.instance_variable_get(instance_var)` — class-level static config.
32
+ 3. Walk `superclass` chain up to (but not including) `BugBunny::Resource`.
33
+
34
+ This means a subclass can override the parent's config, and `.with` overrides everything for the duration of its block.
35
+
36
+ ---
37
+
38
+ ### How does ConsumerMiddleware::Stack protect against concurrent registration?
39
+
40
+ `Stack#use` wraps the append in a `@mutex.synchronize` block. The execution path (`call`) does not use the mutex — it reads `@middlewares` once at call time (snapshot). This is safe because Rails/Zeitwerk loads middleware registrations at boot, before any concurrent requests.
41
+
42
+ ---
43
+
44
+ ### How does routing with namespaces work internally?
45
+
46
+ `namespace :admin { resources :users }` generates `Route` objects with:
47
+ - `path_prefix: 'admin'` — prepended to the path pattern.
48
+ - `namespace: 'Admin::Controllers'` — overrides the default namespace for `constantize`.
49
+
50
+ In `process_message`, the Consumer resolves `base_namespace = route_info[:namespace] || config.controller_namespace` before calling `constantize`.
51
+
52
+ ---
53
+
54
+ ### What is the RCE prevention in the Consumer?
55
+
56
+ After `constantize`, the Consumer checks `controller_class < BugBunny::Controller`. This prevents an attacker from crafting a message with a `type` header pointing to an arbitrary Ruby class (e.g., `Kernel` or `File`) and triggering its methods. Any class not inheriting from `BugBunny::Controller` gets a 403 reply and the message is rejected.
57
+
58
+ ---
59
+
60
+ ### How does `before_action` halt chain propagation?
61
+
62
+ `run_before_actions` iterates before-actions and calls each. After each call, it checks `rendered_response`. If a filter called `render(...)`, `@rendered_response` is set. The method returns `false`, and `core_execution` skips the action and `after_actions`. After-actions do not run if the before-action chain was halted.
63
+
64
+ ---
65
+
66
+ ### How does `after_action` differ from `around_action`?
67
+
68
+ `after_action` runs after the action method returns, but only if no `before_action` halted the chain and no exception was raised. `around_action` wraps the entire execution including before/after actions and is responsible for yielding. Use `around_action` when you need cleanup regardless of exceptions.
69
+
70
+ ---
71
+
72
+ ### How does `safe_log` prevent log failures from affecting main flow?
73
+
74
+ `safe_log` wraps every logger call in `rescue StandardError`. It also filters sensitive keys by checking if the key string matches a predefined set of patterns (`password`, `pass`, `passwd`, `secret`, `token`, `api_key`, `auth`) before emitting values. Blocks are always passed to `logger.debug` to avoid string interpolation cost at non-debug levels.
75
+
76
+ ---
77
+
78
+ ### How is the Consumer health check implemented?
79
+
80
+ `start_health_check` creates a `Concurrent::TimerTask` that runs every `health_check_interval` seconds. Each tick calls `channel.queue_declare(queue_name, passive: true)` — a passive declare that verifies the queue exists without creating it. On failure, it calls `session.close`, which triggers the reconnect loop in `subscribe`. The health file is touched on success.
81
+
82
+ ---
83
+
84
+ ### What triggers the reconnect loop in Consumer?
85
+
86
+ Any `StandardError` raised inside the `subscribe` rescue block. The loop uses exponential backoff: `wait = [interval * 2^(attempt-1), max_interval].min`. If `max_reconnect_attempts` is set and exceeded, the error is re-raised, killing the process.
@@ -0,0 +1,45 @@
1
+ ## Glossary
2
+
3
+ **AMQP** — Advanced Message Queuing Protocol. The binary wire protocol used by RabbitMQ. BugBunny uses Bunny as its AMQP client.
4
+
5
+ **Exchange** — A RabbitMQ routing point. Publishers send messages to exchanges; exchanges route them to queues based on bindings and routing keys. BugBunny supports `direct`, `topic`, and `fanout` types.
6
+
7
+ **Queue** — A buffer that holds messages until they are consumed. A queue is bound to an exchange with a routing key pattern.
8
+
9
+ **Routing key** — A string label attached to a published message. The exchange uses it to decide which queues receive the message. In BugBunny, the routing key defaults to the pluralized resource name (e.g., `users`).
10
+
11
+ **Binding** — The link between an exchange and a queue, specifying which routing key patterns match.
12
+
13
+ **Session** — `BugBunny::Session`. A wrapper around a Bunny channel. Declares exchanges and queues, caches their AMQP objects, and handles double-checked locking for thread safety.
14
+
15
+ **Producer** — `BugBunny::Producer`. Publishes messages to RabbitMQ. Implements both RPC (blocking) and fire-and-forget (non-blocking) modes. One Producer is cached per connection slot.
16
+
17
+ **Consumer** — `BugBunny::Consumer`. The subscribe loop that runs in a worker process. Receives messages, routes them through middleware and the router, dispatches to a controller, and sends RPC replies.
18
+
19
+ **RPC (Remote Procedure Call)** — A synchronous request-response pattern over RabbitMQ. The producer publishes with `reply_to: 'amq.rabbitmq.reply-to'` and blocks on a `Concurrent::IVar` until the consumer replies to that pseudo-queue.
20
+
21
+ **Fire-and-forget** — An asynchronous publish with no reply expected. The producer does not block. Used for events, notifications, and side effects.
22
+
23
+ **Direct Reply-to** — A RabbitMQ pseudo-queue (`amq.rabbitmq.reply-to`) that allows RPC replies without declaring a temporary queue. BugBunny uses this for all RPC responses.
24
+
25
+ **Controller** — `BugBunny::Controller`. A class that handles an incoming routed message. Mirrors Rails ActionController: supports `before_action`, `around_action`, `after_action`, `rescue_from`, `params`, and `render`.
26
+
27
+ **Resource** — `BugBunny::Resource`. An ActiveRecord-like ORM over AMQP. Wraps CRUD operations as RPC calls. Used by the publishing side to interact with a remote service.
28
+
29
+ **Client** — `BugBunny::Client`. High-level API for the publisher side. Implements the Onion Middleware (Faraday-style) pattern around the Producer.
30
+
31
+ **ConsumerMiddleware** — A pipeline of middleware objects that runs before each message is dispatched to a controller. Used for cross-cutting concerns: tracing, authentication, logging.
32
+
33
+ **ConnectionPool** — A `connection_pool` gem pool of Bunny connections. Each slot holds one connection, one Session, and one Producer. `Resource.connection_pool` must be set before making AMQP calls.
34
+
35
+ **IVar (Ivar / Concurrent::IVar)** — An immutable variable from the `concurrent-ruby` gem. Used to block the calling thread until the RPC reply arrives or the timeout expires.
36
+
37
+ **health_check_file** — A file path that BugBunny touches periodically (every `health_check_interval` seconds) after verifying the RabbitMQ connection. Used as a liveness probe in Docker/Kubernetes.
38
+
39
+ **rpc_reply_headers** — A `Proc` returning a `Hash` of AMQP headers injected into every RPC reply. Used to propagate trace context from consumer back to producer.
40
+
41
+ **on_rpc_reply** — A `Proc` called on the producer's thread when an RPC reply arrives, with the reply's AMQP headers. Used to hydrate trace context in the calling service.
42
+
43
+ **Namespace routing** — A DSL feature that prefixes a group of routes with a module namespace and a path prefix. Example: `namespace :admin` generates routes under `Admin::Controllers::*Controller` with path prefix `admin/`.
44
+
45
+ **param_key** — The root key used to wrap the payload in POST/PUT requests. Defaults to the singularized resource name (e.g., `user` for `UsersResource`).
data/docs/concepts.md ADDED
@@ -0,0 +1,140 @@
1
+ # Concepts
2
+
3
+ ## What is BugBunny?
4
+
5
+ BugBunny is a Ruby gem that implements a RESTful routing layer over AMQP (RabbitMQ). It lets microservices communicate through RabbitMQ using familiar HTTP-like patterns: verbs (GET, POST, PUT, DELETE), controllers, declarative routes, synchronous RPC, and fire-and-forget publishing.
6
+
7
+ **Problem it solves:** Direct HTTP coupling between microservices creates a tight dependency graph — if Service B is down, Service A fails immediately. RabbitMQ as a message bus decouples availability, but raw AMQP APIs are low-level and verbose. BugBunny gives you the ergonomics of a web framework on top of the reliability of a message broker.
8
+
9
+ ---
10
+
11
+ ## AMQP in 5 Minutes
12
+
13
+ AMQP (Advanced Message Queuing Protocol) is the protocol RabbitMQ implements. The key concepts:
14
+
15
+ **Exchange** — Receives messages from producers and routes them to queues based on rules. Types:
16
+ - `direct` — Routes to queues whose binding key exactly matches the routing key.
17
+ - `topic` — Routes using wildcard patterns (`orders.*`, `#.error`).
18
+ - `fanout` — Broadcasts to all bound queues regardless of routing key.
19
+
20
+ **Queue** — Stores messages until a consumer picks them up. Durable queues survive broker restarts.
21
+
22
+ **Routing Key** — A string the producer attaches to the message. The exchange uses it to decide which queues receive the message.
23
+
24
+ **Binding** — A link between an exchange and a queue, optionally with a routing key pattern.
25
+
26
+ In BugBunny, the **path** of the message (e.g., `nodes/123`) travels inside the AMQP `type` header. The routing key determines *which service* receives the message; the path determines *which controller and action* handles it inside that service.
27
+
28
+ ---
29
+
30
+ ## Architecture
31
+
32
+ ```
33
+ Service A (Producer)
34
+ BugBunny::Resource.find(id)
35
+ BugBunny::Client#request(path)
36
+ |
37
+ v
38
+ BugBunny::Producer
39
+ | publishes to exchange with:
40
+ | routing_key: 'nodes'
41
+ | type header: 'nodes/123'
42
+ | reply_to: 'amq.rabbitmq.reply-to' (RPC only)
43
+ | correlation_id: 'abc-123' (RPC only)
44
+ v
45
+ [ RabbitMQ Exchange ]
46
+ |
47
+ v
48
+ [ RabbitMQ Queue ]
49
+ |
50
+ v
51
+ Service B (Consumer)
52
+ BugBunny::Consumer (subscribe loop)
53
+ |
54
+ v
55
+ ConsumerMiddleware::Stack
56
+ (tracing, auth, etc.)
57
+ |
58
+ v
59
+ Router (BugBunny.routes)
60
+ matches 'GET nodes/123' → NodesController#show
61
+ |
62
+ v
63
+ NodesController#show
64
+ render status: :ok, json: node
65
+ |
66
+ v (RPC only)
67
+ reply → amq.rabbitmq.reply-to
68
+ |
69
+ v
70
+ Service A (unblocked)
71
+ future.value → { body: {...}, headers: {...} }
72
+ ```
73
+
74
+ ---
75
+
76
+ ## RPC vs Fire-and-Forget
77
+
78
+ BugBunny supports two communication patterns. Choosing the right one matters for system design.
79
+
80
+ ### Synchronous RPC (`:rpc`)
81
+
82
+ The producer blocks until the consumer replies. Uses RabbitMQ's `amq.rabbitmq.reply-to` pseudo-queue — no temporary queues are created.
83
+
84
+ ```ruby
85
+ response = client.request('users/42', method: :get)
86
+ # blocks here until the consumer sends back a reply (or timeout)
87
+ ```
88
+
89
+ **Use when:** Service A needs the result to continue. Example: fetching user data before building a response, validating inventory before placing an order.
90
+
91
+ **Timeout:** Configurable via `config.rpc_timeout`. Raises `BugBunny::RequestTimeout` if the consumer does not reply in time.
92
+
93
+ **Cost:** Ties up a thread in Service A for the duration of the call.
94
+
95
+ ### Fire-and-Forget (`:publish`)
96
+
97
+ The producer publishes and continues immediately. No reply is expected or waited for.
98
+
99
+ ```ruby
100
+ client.publish('events', body: { type: 'order.placed', order_id: 99 })
101
+ # returns immediately with { 'status' => 202 }
102
+ ```
103
+
104
+ **Use when:** Service A does not need a result. Example: emitting audit events, triggering background jobs, sending notifications.
105
+
106
+ **Cost:** None — but you have no confirmation that the message was processed successfully.
107
+
108
+ ---
109
+
110
+ ## Key Components
111
+
112
+ | Class | Role |
113
+ |---|---|
114
+ | `BugBunny::Configuration` | Global settings. Validates required fields on `BugBunny.configure`. |
115
+ | `BugBunny::Session` | Wraps a Bunny channel. Declares exchanges and queues. Thread-safe with double-checked locking. |
116
+ | `BugBunny::Producer` | Publishes messages. Implements RPC with `Concurrent::IVar`. |
117
+ | `BugBunny::Client` | High-level publisher API. Manages a connection pool and middleware stack. |
118
+ | `BugBunny::Consumer` | Subscribe loop. Routes messages to controllers via `BugBunny.routes`. |
119
+ | `BugBunny::ConsumerMiddleware::Stack` | Pipeline of middlewares executed before `process_message`. |
120
+ | `BugBunny::Controller` | Base class for message handlers. Supports `before_action`, `after_action`, `around_action`, `rescue_from`. |
121
+ | `BugBunny::Resource` | ActiveRecord-like ORM over AMQP. Provides `find`, `where`, `create`, `save`, `destroy`. |
122
+ | `BugBunny::Routing::RouteSet` | Stores and matches declared routes. |
123
+ | `BugBunny::Observability` | Mixin for structured logging. `safe_log` never raises. |
124
+
125
+ ---
126
+
127
+ ## Connection Pool
128
+
129
+ BugBunny uses the `connection_pool` gem to share connections safely across threads (Puma workers, Sidekiq threads).
130
+
131
+ ```
132
+ ConnectionPool
133
+ slot 0: Bunny::Session → BugBunny::Session → BugBunny::Producer
134
+ slot 1: Bunny::Session → BugBunny::Session → BugBunny::Producer
135
+ slot N: ...
136
+ ```
137
+
138
+ Each pool slot caches its `Session` and `Producer` for the lifetime of the slot. This avoids re-creating AMQP channels (expensive) and prevents the double `basic_consume` error that would occur if a new Producer were created on a reused channel.
139
+
140
+ Thread safety is guaranteed by `ConnectionPool` itself: each slot is used by one thread at a time, so no additional mutex is needed at the Session or Producer level.
@@ -0,0 +1,194 @@
1
+ # Controllers
2
+
3
+ Controllers receive routed messages and produce responses. They follow the same lifecycle as ActionController in Rails.
4
+
5
+ ## Defining a Controller
6
+
7
+ ```ruby
8
+ module BugBunny
9
+ module Controllers
10
+ class NodesController < BugBunny::Controller
11
+ def index
12
+ nodes = Node.all
13
+ render status: :ok, json: nodes.map(&:as_json)
14
+ end
15
+
16
+ def show
17
+ node = Node.find(params[:id])
18
+ render status: :ok, json: node.as_json
19
+ end
20
+
21
+ def create
22
+ node = Node.new(node_params)
23
+ if node.save
24
+ render status: :created, json: node.as_json
25
+ else
26
+ render status: :unprocessable_entity, json: { errors: node.errors }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ ```
33
+
34
+ The controller namespace defaults to `BugBunny::Controllers`. Override it globally with `config.controller_namespace`, or per route group with `namespace` blocks in the DSL.
35
+
36
+ ---
37
+
38
+ ## params
39
+
40
+ `params` merges path parameters, query string parameters, and body parameters into a single `HashWithIndifferentAccess`:
41
+
42
+ ```ruby
43
+ # Message for PUT nodes/42?verbose=true with body { "node": { "status": "active" } }
44
+ params[:id] # => "42" (from path)
45
+ params[:verbose] # => "true" (from query string)
46
+ params[:node][:status] # => "active" (from JSON body)
47
+ ```
48
+
49
+ Use strong-parameters style to extract what you need:
50
+
51
+ ```ruby
52
+ def node_params
53
+ params.require(:node).permit(:name, :status)
54
+ end
55
+ ```
56
+
57
+ ---
58
+
59
+ ## before_action
60
+
61
+ Runs before the action. Rendering inside a `before_action` halts the chain — the action and `after_action` callbacks do not run.
62
+
63
+ ```ruby
64
+ class ApplicationController < BugBunny::Controller
65
+ before_action :authenticate!
66
+ before_action :set_node, only: [:show, :update, :destroy, :drain]
67
+
68
+ private
69
+
70
+ def authenticate!
71
+ token = request_headers['X-Service-Token']
72
+ render status: :unauthorized, json: { error: 'Unauthorized' } unless valid_token?(token)
73
+ end
74
+
75
+ def set_node
76
+ @node = Node.find(params[:id])
77
+ render status: :not_found, json: { error: 'Not found' } unless @node
78
+ end
79
+ end
80
+ ```
81
+
82
+ ---
83
+
84
+ ## after_action
85
+
86
+ Runs after the action completes successfully. Skipped if a `before_action` halted the chain or if the action raised an exception — same behavior as Rails.
87
+
88
+ ```ruby
89
+ class NodesController < ApplicationController
90
+ after_action :emit_audit_event, only: [:create, :update, :destroy]
91
+
92
+ private
93
+
94
+ def emit_audit_event
95
+ AuditLog.record(action: action_name, node_id: params[:id], actor: current_service)
96
+ end
97
+ end
98
+ ```
99
+
100
+ ---
101
+
102
+ ## around_action
103
+
104
+ Wraps the action. Must call `yield` to execute the inner chain.
105
+
106
+ ```ruby
107
+ class NodesController < ApplicationController
108
+ around_action :with_distributed_lock, only: [:drain]
109
+
110
+ private
111
+
112
+ def with_distributed_lock
113
+ DistributedLock.acquire("node:#{params[:id]}") { yield }
114
+ end
115
+ end
116
+ ```
117
+
118
+ ---
119
+
120
+ ## rescue_from
121
+
122
+ Catches exceptions raised during the action and maps them to responses. Handlers are inherited and can be defined in a base `ApplicationController`.
123
+
124
+ ```ruby
125
+ class ApplicationController < BugBunny::Controller
126
+ rescue_from ActiveRecord::RecordNotFound do |e|
127
+ render status: :not_found, json: { error: e.message }
128
+ end
129
+
130
+ rescue_from ActiveRecord::RecordInvalid do |e|
131
+ render status: :unprocessable_entity, json: { errors: e.record.errors.full_messages }
132
+ end
133
+
134
+ rescue_from StandardError do |e|
135
+ logger.error("Unhandled error: #{e.class} — #{e.message}")
136
+ render status: :internal_server_error, json: { error: 'Internal server error' }
137
+ end
138
+ end
139
+ ```
140
+
141
+ ---
142
+
143
+ ## render
144
+
145
+ ```ruby
146
+ render status: :ok, json: resource.as_json
147
+ render status: :created, json: { id: record.id }
148
+ render status: :no_content, json: nil
149
+ render status: :unprocessable_entity, json: { errors: object.errors }
150
+ render status: :not_found, json: { error: 'Not found' }
151
+ ```
152
+
153
+ Accepts any Rack status symbol (`:ok`, `:created`, `:not_found`, etc.) or an integer status code.
154
+
155
+ ### Adding response headers
156
+
157
+ ```ruby
158
+ # Per-response headers (merged, non-destructive)
159
+ render status: :ok, json: data, headers: { 'X-Request-Id' => request_id }
160
+
161
+ # Headers set for the lifetime of the action (accessible via response_headers)
162
+ def show
163
+ response_headers['X-Cache'] = 'HIT'
164
+ render status: :ok, json: Node.find(params[:id]).as_json
165
+ end
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Accessing AMQP headers
171
+
172
+ ```ruby
173
+ def create
174
+ trace_id = request_headers['X-Trace-Id']
175
+ # ...
176
+ end
177
+ ```
178
+
179
+ `request_headers` returns the raw AMQP headers hash from `properties.headers`.
180
+
181
+ ---
182
+
183
+ ## log_tags
184
+
185
+ Injects contextual tags into all log lines produced within the action's execution:
186
+
187
+ ```ruby
188
+ class NodesController < ApplicationController
189
+ log_tag { params[:id] }
190
+ log_tag { current_tenant }
191
+ end
192
+ ```
193
+
194
+ Works with Rails' `ActiveSupport::TaggedLogging`.