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,119 @@
1
+ # Client Middleware
2
+
3
+ The client middleware stack wraps outgoing requests in an onion (Faraday-style). Each middleware can inspect and modify the request before it reaches the producer, and inspect the response on the way back.
4
+
5
+ ## Built-in Middlewares
6
+
7
+ ### `BugBunny::Middleware::RaiseError`
8
+
9
+ Maps HTTP-like status codes in the response to Ruby exceptions:
10
+
11
+ | Status | Exception |
12
+ |--------|-----------|
13
+ | 400 | `BugBunny::BadRequest` |
14
+ | 401 | `BugBunny::Unauthorized` |
15
+ | 403 | `BugBunny::Forbidden` |
16
+ | 404 | `BugBunny::NotFound` |
17
+ | 409 | `BugBunny::Conflict` |
18
+ | 422 | `BugBunny::UnprocessableEntity` |
19
+ | 4xx | `BugBunny::ClientError` |
20
+ | 500 | `BugBunny::InternalServerError` |
21
+ | 5xx | `BugBunny::ServerError` |
22
+ | Timeout| `BugBunny::RequestTimeout` |
23
+
24
+ ### `BugBunny::Middleware::JsonResponse`
25
+
26
+ Parses the response body from JSON and returns a `HashWithIndifferentAccess`. Without this middleware, the response body is a raw String.
27
+
28
+ ---
29
+
30
+ ## Using Middlewares with Client
31
+
32
+ ```ruby
33
+ pool = ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
34
+ client = BugBunny::Client.new(pool: pool) do |stack|
35
+ stack.use BugBunny::Middleware::RaiseError
36
+ stack.use BugBunny::Middleware::JsonResponse
37
+ stack.use MyLoggingMiddleware
38
+ end
39
+ ```
40
+
41
+ Middlewares execute in the order they are registered (FIFO). `RaiseError` and `JsonResponse` should be registered in that order so that `RaiseError` sees the parsed body.
42
+
43
+ ---
44
+
45
+ ## Writing a Custom Middleware
46
+
47
+ Inherit from `BugBunny::Middleware::Base` and implement `call`:
48
+
49
+ ```ruby
50
+ class RequestLoggingMiddleware < BugBunny::Middleware::Base
51
+ def call(request)
52
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
53
+ response = app.call(request)
54
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
55
+
56
+ Rails.logger.info("amqp_request path=#{request.path} method=#{request.method} duration_s=#{duration.round(4)}")
57
+ response
58
+ end
59
+ end
60
+ ```
61
+
62
+ `app.call(request)` invokes the next middleware in the stack (or the producer at the end of the chain). The return value is the response hash `{ 'status' => Integer, 'body' => ..., 'headers' => Hash }`.
63
+
64
+ ### Modifying the request
65
+
66
+ ```ruby
67
+ class InjectTraceHeaderMiddleware < BugBunny::Middleware::Base
68
+ def call(request)
69
+ request.headers['X-Trace-Id'] = MyTracer.current_trace_id
70
+ app.call(request)
71
+ end
72
+ end
73
+ ```
74
+
75
+ ### Modifying the response
76
+
77
+ ```ruby
78
+ class ResponseCachingMiddleware < BugBunny::Middleware::Base
79
+ def call(request)
80
+ cached = Cache.get(request.path)
81
+ return cached if cached
82
+
83
+ response = app.call(request)
84
+ Cache.set(request.path, response, ttl: 30) if response['status'] == 200
85
+ response
86
+ end
87
+ end
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Middlewares on Resource
93
+
94
+ `BugBunny::Resource` uses `RaiseError` and `JsonResponse` by default. Add custom middlewares via the class-level DSL:
95
+
96
+ ```ruby
97
+ class RemoteNode < BugBunny::Resource
98
+ client_middleware do |stack|
99
+ stack.use RequestLoggingMiddleware
100
+ stack.use InjectTraceHeaderMiddleware
101
+ end
102
+ end
103
+ ```
104
+
105
+ Custom middlewares are injected after the core ones, so `RaiseError` and `JsonResponse` always run first.
106
+
107
+ Middlewares defined in a parent class are inherited:
108
+
109
+ ```ruby
110
+ class ApplicationResource < BugBunny::Resource
111
+ client_middleware do |stack|
112
+ stack.use InjectTraceHeaderMiddleware
113
+ end
114
+ end
115
+
116
+ class RemoteNode < ApplicationResource
117
+ # inherits InjectTraceHeaderMiddleware
118
+ end
119
+ ```
@@ -0,0 +1,127 @@
1
+ # Consumer Middleware
2
+
3
+ The consumer middleware stack runs before every message reaches the router. It is the right place for cross-cutting concerns: distributed tracing, authentication, audit logging, rate limiting.
4
+
5
+ ## Execution Order
6
+
7
+ ```
8
+ RabbitMQ message arrives
9
+ |
10
+ v
11
+ ConsumerMiddleware::Stack
12
+ Middleware A (first registered)
13
+ Middleware B
14
+ Middleware C
15
+ process_message (router → controller → action)
16
+ Middleware C (post-processing)
17
+ Middleware B (post-processing)
18
+ Middleware A (post-processing)
19
+ ```
20
+
21
+ Middlewares execute FIFO. If no middlewares are registered, the overhead is zero.
22
+
23
+ ---
24
+
25
+ ## Writing a Middleware
26
+
27
+ Inherit from `BugBunny::ConsumerMiddleware::Base` and implement `call`:
28
+
29
+ ```ruby
30
+ class TracingMiddleware < BugBunny::ConsumerMiddleware::Base
31
+ def call(delivery_info, properties, body)
32
+ trace_id = properties.headers&.dig('X-Trace-Id') || SecureRandom.uuid
33
+ MyTracer.with_trace(trace_id) do
34
+ @app.call(delivery_info, properties, body)
35
+ end
36
+ end
37
+ end
38
+ ```
39
+
40
+ `@app.call(delivery_info, properties, body)` passes control to the next middleware. Always call it (unless you intentionally want to halt processing).
41
+
42
+ ### Available data
43
+
44
+ | Argument | Type | Contents |
45
+ |---|---|---|
46
+ | `delivery_info` | `Bunny::DeliveryInfo` | `routing_key`, `exchange`, `delivery_tag`, `redelivered` |
47
+ | `properties` | `Bunny::MessageProperties` | `headers` (custom AMQP headers), `correlation_id`, `reply_to`, `content_type` |
48
+ | `body` | `String` | Raw message payload (typically JSON) |
49
+
50
+ ### Post-processing
51
+
52
+ Code after `@app.call` runs once the message has been fully processed (controller action completed):
53
+
54
+ ```ruby
55
+ class AuditMiddleware < BugBunny::ConsumerMiddleware::Base
56
+ def call(delivery_info, properties, body)
57
+ @app.call(delivery_info, properties, body)
58
+ rescue => e
59
+ AuditLog.record_failure(routing_key: delivery_info.routing_key, error: e.class.name)
60
+ raise
61
+ ensure
62
+ AuditLog.record_received(routing_key: delivery_info.routing_key)
63
+ end
64
+ end
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Registering Middlewares
70
+
71
+ ```ruby
72
+ # After BugBunny.configure
73
+ BugBunny.consumer_middlewares.use TracingMiddleware
74
+ BugBunny.consumer_middlewares.use AuditMiddleware
75
+ BugBunny.consumer_middlewares.use AuthenticationMiddleware
76
+ ```
77
+
78
+ Registrations are thread-safe. You can register middlewares at any point before the Consumer starts subscribing.
79
+
80
+ ---
81
+
82
+ ## Auto-registration from External Gems
83
+
84
+ Integration gems can register themselves transparently when required, without the user modifying the `configure` block:
85
+
86
+ ```ruby
87
+ # lib/my_tracing_gem/bug_bunny.rb
88
+ require 'my_tracing_gem/bug_bunny/consumer_middleware'
89
+ BugBunny.consumer_middlewares.use MyTracingGem::BugBunny::ConsumerMiddleware
90
+ ```
91
+
92
+ The user only needs:
93
+
94
+ ```ruby
95
+ require 'my_tracing_gem/bug_bunny'
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Halting Message Processing
101
+
102
+ To reject a message without routing it (e.g., authentication failure):
103
+
104
+ ```ruby
105
+ class AuthMiddleware < BugBunny::ConsumerMiddleware::Base
106
+ def call(delivery_info, properties, body)
107
+ token = properties.headers&.dig('X-Service-Token')
108
+ unless TokenValidator.valid?(token)
109
+ # Do not call @app — message is effectively dropped from this consumer's perspective
110
+ # The channel will nack/reject it based on the consumer's error handling
111
+ return
112
+ end
113
+
114
+ @app.call(delivery_info, properties, body)
115
+ end
116
+ end
117
+ ```
118
+
119
+ Note: halting in a middleware skips all routing and controller logic, but the message is still acknowledged at the AMQP level (the Consumer's normal ack happens in `process_message`, which was never reached). If you need to nack/reject, interact with `delivery_info.delivery_tag` directly — but this is an advanced use case.
120
+
121
+ ---
122
+
123
+ ## Inspecting the Stack
124
+
125
+ ```ruby
126
+ BugBunny.consumer_middlewares.empty? # => false if any middleware registered
127
+ ```
@@ -0,0 +1,214 @@
1
+ # Rails Setup
2
+
3
+ Complete integration guide for BugBunny in a Rails application using Puma and/or Sidekiq.
4
+
5
+ ## Gemfile
6
+
7
+ ```ruby
8
+ gem 'bug_bunny'
9
+ gem 'connection_pool'
10
+ ```
11
+
12
+ ## Generator
13
+
14
+ ```bash
15
+ rails generate bug_bunny:install
16
+ ```
17
+
18
+ Creates `config/initializers/bug_bunny.rb` with a commented template.
19
+
20
+ ---
21
+
22
+ ## Initializer
23
+
24
+ ```ruby
25
+ # config/initializers/bug_bunny.rb
26
+
27
+ BugBunny.configure do |config|
28
+ config.host = ENV.fetch('RABBITMQ_HOST', 'localhost')
29
+ config.port = ENV.fetch('RABBITMQ_PORT', '5672').to_i
30
+ config.username = ENV.fetch('RABBITMQ_USER', 'guest')
31
+ config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
32
+ config.vhost = ENV.fetch('RABBITMQ_VHOST', '/')
33
+
34
+ config.rpc_timeout = 30
35
+ config.max_reconnect_attempts = 10
36
+ config.max_reconnect_interval = 60
37
+ config.network_recovery_interval = 5
38
+
39
+ config.exchange_options = { durable: true }
40
+ config.queue_options = { durable: true }
41
+
42
+ config.logger = Rails.logger
43
+
44
+ # Kubernetes / Docker Swarm liveness probe
45
+ config.health_check_file = Rails.root.join('tmp', 'bug_bunny_health').to_s
46
+ end
47
+
48
+ # Shared connection pool — size should match Puma/Sidekiq thread count
49
+ BUG_BUNNY_POOL = ConnectionPool.new(
50
+ size: ENV.fetch('RAILS_MAX_THREADS', 5).to_i,
51
+ timeout: 5
52
+ ) do
53
+ BugBunny.create_connection
54
+ end
55
+
56
+ # Make the pool available to all Resource classes
57
+ BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Routes
63
+
64
+ ```ruby
65
+ # config/initializers/bug_bunny_routes.rb
66
+ BugBunny.routes.draw do
67
+ resources :users
68
+ resources :orders do
69
+ member { post :cancel }
70
+ end
71
+ end
72
+ ```
73
+
74
+ Keep routes in a dedicated initializer so they load independently from `config/routes.rb`.
75
+
76
+ ---
77
+
78
+ ## Directory Layout
79
+
80
+ The Rails generator configures Zeitwerk to autoload `app/rabbit`:
81
+
82
+ ```
83
+ app/
84
+ rabbit/
85
+ controllers/ # BugBunny controllers (loaded under BugBunny::Controllers)
86
+ application_controller.rb
87
+ users_controller.rb
88
+ workers/ # Consumer processes (optional)
89
+ inventory_worker.rb
90
+ ```
91
+
92
+ Or use the configurable controller namespace:
93
+
94
+ ```ruby
95
+ config.controller_namespace = 'Rabbit::Controllers'
96
+ # → app/rabbit/controllers/ maps to Rabbit::Controllers
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Consumer Workers
102
+
103
+ The Consumer is a blocking subscribe loop. Run it in a dedicated process (not inside Puma).
104
+
105
+ ### Rake task
106
+
107
+ ```ruby
108
+ # lib/tasks/rabbit.rake
109
+ namespace :rabbit do
110
+ desc 'Start the RabbitMQ consumer'
111
+ task inventory: :environment do
112
+ consumer = BugBunny::Consumer.new
113
+ trap('TERM') { consumer.shutdown; exit }
114
+ trap('INT') { consumer.shutdown; exit }
115
+
116
+ consumer.subscribe(
117
+ queue_name: 'inventory_queue',
118
+ exchange_name: 'inventory_exchange',
119
+ routing_key: 'inventory',
120
+ block: true
121
+ )
122
+ end
123
+ end
124
+ ```
125
+
126
+ ```bash
127
+ bundle exec rake rabbit:inventory
128
+ ```
129
+
130
+ ### Dockerfile entrypoint (separate service)
131
+
132
+ ```dockerfile
133
+ # Dockerfile.worker
134
+ CMD ["bundle", "exec", "rake", "rabbit:inventory"]
135
+ ```
136
+
137
+ ### Multiple consumers
138
+
139
+ Run one process per queue. Each process has its own connection pool.
140
+
141
+ ---
142
+
143
+ ## Puma Fork Safety
144
+
145
+ When Puma forks workers, open AMQP connections in the parent process become invalid in children. BugBunny's Railtie handles this automatically for Puma via `on_worker_boot`:
146
+
147
+ ```ruby
148
+ # Railtie registers this automatically:
149
+ Puma::Server.on_worker_boot do
150
+ BugBunny.reconnect! if defined?(BUG_BUNNY_POOL)
151
+ end
152
+ ```
153
+
154
+ If you use a custom pool variable name, add the hook manually:
155
+
156
+ ```ruby
157
+ # config/puma.rb
158
+ on_worker_boot do
159
+ MY_CUSTOM_POOL.reload_connections { BugBunny.create_connection }
160
+ end
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Sidekiq Integration
166
+
167
+ Sidekiq uses threads, not forks, so no special handling is needed. The shared `BUG_BUNNY_POOL` is thread-safe. Sidekiq jobs can call `BugBunny::Resource` methods directly.
168
+
169
+ Set the pool size to match Sidekiq's concurrency:
170
+
171
+ ```ruby
172
+ BUG_BUNNY_POOL = ConnectionPool.new(
173
+ size: ENV.fetch('SIDEKIQ_CONCURRENCY', 10).to_i,
174
+ timeout: 5
175
+ ) { BugBunny.create_connection }
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Health Checks (Kubernetes / Docker Swarm)
181
+
182
+ The Consumer's internal heartbeat timer touches `config.health_check_file` every 30 seconds after verifying the RabbitMQ connection and queue are alive.
183
+
184
+ ```yaml
185
+ # docker-compose.yml
186
+ healthcheck:
187
+ test: ["CMD", "test", "-f", "/app/tmp/bug_bunny_health"]
188
+ interval: 60s
189
+ timeout: 5s
190
+ retries: 3
191
+ start_period: 30s # allow time for Rails boot + first heartbeat
192
+ ```
193
+
194
+ ```yaml
195
+ # Kubernetes
196
+ livenessProbe:
197
+ exec:
198
+ command: ["test", "-f", "/app/tmp/bug_bunny_health"]
199
+ initialDelaySeconds: 30
200
+ periodSeconds: 60
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Graceful Shutdown
206
+
207
+ The `Consumer#shutdown` method stops the heartbeat timer and closes the AMQP channel cleanly. It is also called automatically when `subscribe` exits for any reason.
208
+
209
+ ```ruby
210
+ consumer = BugBunny::Consumer.new
211
+ trap('TERM') { consumer.shutdown; exit 0 }
212
+ trap('INT') { consumer.shutdown; exit 0 }
213
+ consumer.subscribe(...)
214
+ ```
@@ -0,0 +1,200 @@
1
+ # Resource ORM
2
+
3
+ `BugBunny::Resource` provides an ActiveRecord-like interface for remote services. Each Resource class represents a resource type in another microservice, reachable via RabbitMQ.
4
+
5
+ ## Defining a Resource
6
+
7
+ ```ruby
8
+ class RemoteNode < BugBunny::Resource
9
+ # AMQP infrastructure
10
+ self.exchange = 'inventory_exchange'
11
+ self.exchange_type = 'direct' # default
12
+ self.resource_name = 'nodes' # used as the path prefix and routing key
13
+
14
+ # Typed attributes (ActiveModel::Attributes)
15
+ attribute :name, :string
16
+ attribute :status, :string
17
+ attribute :cpu_cores, :integer
18
+ attribute :active, :boolean
19
+
20
+ # Validations (ActiveModel::Validations)
21
+ validates :name, presence: true
22
+ validates :status, inclusion: { in: %w[pending active draining decommissioned] }
23
+ end
24
+ ```
25
+
26
+ ## Connection Pool
27
+
28
+ All Resource classes in a service typically share one pool:
29
+
30
+ ```ruby
31
+ # config/initializers/bug_bunny.rb
32
+ BUG_BUNNY_POOL = ConnectionPool.new(size: 5, timeout: 5) do
33
+ BugBunny.create_connection
34
+ end
35
+
36
+ BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
37
+ ```
38
+
39
+ Individual classes can override:
40
+
41
+ ```ruby
42
+ RemoteNode.connection_pool = OTHER_POOL
43
+ ```
44
+
45
+ ---
46
+
47
+ ## CRUD
48
+
49
+ ```ruby
50
+ # Find by ID — returns nil on 404
51
+ node = RemoteNode.find('node-123')
52
+
53
+ # List all
54
+ nodes = RemoteNode.all
55
+
56
+ # Filter — query params forwarded to the consumer
57
+ nodes = RemoteNode.where(status: 'active')
58
+ nodes = RemoteNode.where(q: { cpu_cores: 4 }, page: 2)
59
+
60
+ # Create
61
+ node = RemoteNode.create(name: 'web-01', status: 'pending')
62
+ node.persisted? # => true if save succeeded
63
+
64
+ # Update
65
+ node = RemoteNode.find('node-123')
66
+ node.status = 'active'
67
+ node.save # PUT nodes/node-123
68
+
69
+ # Update (shorthand)
70
+ node.update(status: 'active', name: 'web-01')
71
+
72
+ # Destroy
73
+ node.destroy # DELETE nodes/node-123
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Typed vs Dynamic Attributes
79
+
80
+ ### Typed attributes
81
+
82
+ Declared with `attribute :name, :type`. Benefit from ActiveModel coercions, dirty tracking, and validations:
83
+
84
+ ```ruby
85
+ attribute :cpu_cores, :integer
86
+ attribute :enabled, :boolean
87
+ attribute :score, :decimal
88
+ ```
89
+
90
+ ### Dynamic attributes
91
+
92
+ Any key received from the remote service that is not declared as a typed attribute is stored dynamically and accessible via `method_missing`:
93
+
94
+ ```ruby
95
+ node = RemoteNode.find('node-123')
96
+ node.docker_id # => "abc123xyz" (not declared, but present in the response)
97
+ node.docker_id = 'new-id'
98
+ node.changed? # => true
99
+ node.changed # => ['docker_id']
100
+ ```
101
+
102
+ Dynamic attributes participate in `changed?`, `changed`, and `changes_to_send` — so they are serialized correctly on `save`.
103
+
104
+ ---
105
+
106
+ ## Change Tracking
107
+
108
+ BugBunny::Resource merges ActiveModel::Dirty (for typed attributes) with its own tracking for dynamic attributes.
109
+
110
+ ```ruby
111
+ node = RemoteNode.find('node-123') # changes cleared after find
112
+ node.changed? # => false
113
+
114
+ node.status = 'draining'
115
+ node.changed? # => true
116
+ node.changed # => ['status']
117
+
118
+ node.save # sends only changed attrs
119
+ node.changed? # => false (cleared after save)
120
+ ```
121
+
122
+ `save` on a new record (not persisted) sends all attributes.
123
+
124
+ ---
125
+
126
+ ## Validations
127
+
128
+ ```ruby
129
+ node = RemoteNode.new(name: '', status: 'invalid')
130
+ node.valid? # => false
131
+ node.errors.full_messages
132
+ # => ["Name can't be blank", "Status is not included in the list"]
133
+
134
+ node.save # => false (does not send the request)
135
+ ```
136
+
137
+ Remote validation errors (422 responses) are loaded back into the object:
138
+
139
+ ```ruby
140
+ node = RemoteNode.create(name: 'duplicate-name')
141
+ node.persisted? # => false
142
+ node.errors[:name] # => ["has already been taken"]
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Callbacks
148
+
149
+ ```ruby
150
+ class RemoteNode < BugBunny::Resource
151
+ before_save :normalize_name
152
+ after_create :notify_provisioner
153
+ around_save :with_timing
154
+
155
+ private
156
+
157
+ def normalize_name
158
+ self.name = name.to_s.downcase.strip
159
+ end
160
+ end
161
+ ```
162
+
163
+ Available callbacks: `before_save`, `after_save`, `before_create`, `after_create`, `before_update`, `after_update`, `before_destroy`, `after_destroy`.
164
+
165
+ ---
166
+
167
+ ## Dynamic Exchange Configuration with `.with`
168
+
169
+ Override AMQP settings for a single operation without changing the class defaults:
170
+
171
+ ```ruby
172
+ # Different exchange for a single call
173
+ RemoteNode.with(exchange: 'us-east-inventory').where(status: 'active')
174
+
175
+ # Different routing key
176
+ RemoteNode.with(routing_key: 'nodes.priority').find('node-123')
177
+
178
+ # Chain is single-use — call .with again for the next operation
179
+ RemoteNode.with(exchange: 'staging').create(name: 'test-node')
180
+ ```
181
+
182
+ `.with` sets thread-local values that are cleaned up after the single operation completes, even if an exception is raised.
183
+
184
+ ---
185
+
186
+ ## Payload Wrapping
187
+
188
+ By default, `save` wraps the payload under a root key derived from the model name:
189
+
190
+ ```ruby
191
+ # RemoteNode → root key: 'node'
192
+ node.save
193
+ # Sends: { "node" => { "name" => "web-01", "status" => "active" } }
194
+ ```
195
+
196
+ The consumer can then use `params.require(:node)` in the controller. Override the root key:
197
+
198
+ ```ruby
199
+ self.param_key = 'server'
200
+ ```