bug_bunny 3.1.1 → 3.1.3

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: fc4ecbe75edaf8811acfe2929b74fb4aa98cb65c61d74aca4f5a1320b40a94b8
4
- data.tar.gz: c3ed5b1e5a773ab8d80fbecc1b96d4a04a62dd1f0b1b5414a2275f497fcde9ee
3
+ metadata.gz: 4aab7949b09bfbf861d4a9ff736bc3bef832fdb3c8a302924544783bdbc9e1bf
4
+ data.tar.gz: 12b73641a812ec4ce72d2a5ad8992a1f2b91c4b0b11ce181aac02fe3a4912751
5
5
  SHA512:
6
- metadata.gz: ee79317a849f3b62e25791f8c3e2bc73e55e6531c697f04ef7896ff438bfac817c5efef15f170769fc3c441a212776f6cec90d0a02b7a52764208512862df1af
7
- data.tar.gz: a4e7c0a8a5661e285978376b12c13977fcb9c522a4a19b8c15177d81917084dde7b595baa30c8ef142547d2e35a5983abec7ccaf2a8b6a52e044e51b8196889f
6
+ metadata.gz: 2f4475f1754c1de91be6576d4fabb23d025b1f3735d4d76d3123415e71b907d2bfc307dd8e8f4a921ad5a11b2524cd85a1ee17ea3fb1d42f6e15c62ddef01425
7
+ data.tar.gz: c3973288d2d394121491ffb3c7fa809b8841c9fdc3fc87486fb807b62b6f948e48298fd9f4b696132c9b86d448dad82bd4cfa909c39384efc2ab1b7729c15f31
data/CHANGELOG.md CHANGED
@@ -1,4 +1,29 @@
1
1
  # Changelog
2
+ ## [3.1.3] - 2026-02-19
3
+
4
+ ### 🏗️ Architectural Refactoring (Middleware Standardization)
5
+ * **Centralized Error Handling:** Refactored `BugBunny::Resource` to completely delegate HTTP status evaluation to the `RaiseError` middleware. The ORM now operates strictly on a "Happy Path" mentality, rescuing semantic exceptions (`NotFound`, `UnprocessableEntity`) natively.
6
+ * **Middleware Injection Enforcement:** `BugBunny::Resource` now explicitly guarantees that `BugBunny::Middleware::RaiseError` and `BugBunny::Middleware::JsonResponse` are the core of the stack, ensuring consistent data parsing and error raising before any custom user middlewares are executed.
7
+
8
+ ### ✨ New Features & Improvements
9
+ * **Smart Validation Errors:** `BugBunny::UnprocessableEntity` (422) is now intelligent. It automatically parses the remote worker's response payload, gracefully handling string fallbacks or extracting the standard Rails `{ errors: ... }` convention to accurately populate local object validations.
10
+ * **HTTP 409 Conflict Support:** Added native support for `409 Conflict` mapping it to the new `BugBunny::Conflict` exception. Ideal for handling state collisions in distributed systems.
11
+ * **Global Error Formatting:** Moved `format_error_message` directly into the `RaiseError` middleware. Now, even manual `BugBunny::Client` requests will benefit from clean, structured exception messages (e.g., `"Internal Server Error - undefined method"`) optimized for APMs like Sentry or Datadog.
12
+
13
+ ## [3.1.2] - 2026-02-19
14
+
15
+ ### 🐛 Bug Fixes
16
+ * **Controller Callback Inheritance:** Fixed a critical issue where `before_action`, `around_action`, and `rescue_from` definitions in a parent class (like `ApplicationController`) were not being inherited by child controllers. Migrated internal storage to `class_attribute` with deep duplication to ensure isolated, thread-safe inheritance without mutating the parent class.
17
+ * **Gemspec Hygiene:** Updated the `spec.description` to resolve RubyGems identity warnings and added explicit minimum version boundaries for standard dependencies (e.g., `json >= 2.0`).
18
+
19
+ ### 🌟 Observability & DX (Developer Experience)
20
+ * **Structured Remote Errors:** `BugBunny::Resource` now intelligently formats the body of remote errors. When raising a `ClientError` (4xx) or `InternalServerError` (500), it extracts the specific error message (e.g., `"Internal Server Error - undefined method 'foo'"`) or falls back to a readable JSON string. This drastically improves the legibility of remote stack traces in monitoring tools like Sentry or Datadog.
21
+ * **Infrastructure Logging:** The `Consumer` and `Producer` now calculate the final resolved cascade options (`exchange_opts`, `queue_opts`) and explicitly log them during worker startup and message publishing. This provides absolute transparency into what configurations are actually reaching RabbitMQ.
22
+ * **Consumer Cascade Options:** Added the `exchange_opts:` parameter to `Consumer.subscribe` to fully support Level 3 (On-the-fly) infrastructure configuration for manual worker instantiations.
23
+
24
+ ### 📖 Documentation
25
+ * **Built-in Middlewares:** Added comprehensive documentation to the README explaining how to inject and utilize the provided `RaiseError` and `JsonResponse` middlewares when using the manual `BugBunny::Client`.
26
+
2
27
  ## [3.1.1] - 2026-02-19
3
28
 
4
29
  ### 🚀 Features
data/README.md CHANGED
@@ -185,7 +185,27 @@ Manager::Service.with(
185
185
  ```
186
186
 
187
187
  ### 4. Client Middleware (Interceptores)
188
- Intercepta peticiones antes de salir hacia RabbitMQ. Ideal para inyectar Auth o Headers.
188
+ Intercepta peticiones de ida y respuestas de vuelta en la arquitectura del cliente.
189
+
190
+ **Middlewares Incluidos (Built-ins)**
191
+ Si usas `BugBunny::Resource`, el manejo de JSON y de errores ya está integrado automáticamente. Pero si utilizas el cliente manual (`BugBunny::Client`), puedes inyectar los middlewares incluidos para no tener que parsear respuestas manualmente:
192
+
193
+ * `BugBunny::Middleware::JsonResponse`: Parsea automáticamente el cuerpo de la respuesta de JSON a un Hash de Ruby.
194
+ * `BugBunny::Middleware::RaiseError`: Evalúa el código de estado (`status`) de la respuesta y lanza excepciones nativas (`BugBunny::NotFound`, `BugBunny::UnprocessableEntity`, `BugBunny::InternalServerError`, etc.).
195
+
196
+ ```ruby
197
+ # Uso con el cliente manual
198
+ client = BugBunny::Client.new(pool: BUG_BUNNY_POOL) do |stack|
199
+ stack.use BugBunny::Middleware::RaiseError
200
+ stack.use BugBunny::Middleware::JsonResponse
201
+ end
202
+
203
+ # Ahora el cliente devolverá Hashes y lanzará errores si el worker falla
204
+ response = client.request('users/1', method: :get)
205
+ ```
206
+
207
+ **Middlewares Personalizados**
208
+ Ideales para inyectar Auth o Headers de trazabilidad en todos los requests de un Recurso.
189
209
 
190
210
  ```ruby
191
211
  class Manager::Service < BugBunny::Resource
@@ -200,6 +220,30 @@ class Manager::Service < BugBunny::Resource
200
220
  end
201
221
  ```
202
222
 
223
+ **Personalización Avanzada de Errores**
224
+ Si en tu aplicación necesitas mapear códigos HTTP de negocio (ej. `402 Payment Required`) a excepciones personalizadas, la forma más limpia es usar `Module#prepend` sobre el middleware nativo en un inicializador. De esta forma inyectas tus reglas sin perder el comportamiento por defecto para los demás errores:
225
+
226
+ ```ruby
227
+ # config/initializers/bug_bunny_custom_errors.rb
228
+ module CustomBugBunnyErrors
229
+ def on_complete(response)
230
+ status = response['status'].to_i
231
+
232
+ # 1. Reglas específicas de tu negocio
233
+ if status == 402
234
+ raise MyApp::PaymentRequiredError, response['body']['message']
235
+ elsif status == 403 && response['body']['reason'] == 'ip_blocked'
236
+ raise MyApp::IpBlockedError, response['body']['detail']
237
+ end
238
+
239
+ # 2. Delegar el resto de los errores (404, 422, 500) al middleware original
240
+ super(response)
241
+ end
242
+ end
243
+
244
+ BugBunny::Middleware::RaiseError.prepend(CustomBugBunnyErrors)
245
+ ```
246
+
203
247
  ---
204
248
 
205
249
  ## 📡 Modo Servidor: Controladores
@@ -53,15 +53,27 @@ module BugBunny
53
53
  # @param exchange_name [String] Nombre del exchange al cual enlazar la cola.
54
54
  # @param routing_key [String] Patrón de enrutamiento (ej: 'users.*').
55
55
  # @param exchange_type [String] Tipo de exchange ('direct', 'topic', 'fanout').
56
+ # @param exchange_opts [Hash] Opciones adicionales para el exchange (durable, auto_delete).
56
57
  # @param queue_opts [Hash] Opciones adicionales para la cola (durable, auto_delete).
57
58
  # @param block [Boolean] Si es `true`, bloquea el hilo actual (loop infinito).
58
59
  # @return [void]
59
- def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', queue_opts: {}, block: true)
60
- x = session.exchange(name: exchange_name, type: exchange_type)
60
+ def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', exchange_opts: {}, queue_opts: {}, block: true)
61
+ # Declaración de Infraestructura
62
+ x = session.exchange(name: exchange_name, type: exchange_type, opts: exchange_opts)
61
63
  q = session.queue(queue_name, queue_opts)
62
64
  q.bind(x, routing_key: routing_key)
63
65
 
64
- BugBunny.configuration.logger.info("[BugBunny::Consumer] 🎧 Listening on '#{queue_name}' | Exchange: '#{exchange_name}' | Routing Key: '#{routing_key}'")
66
+ # 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
67
+ final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
68
+ .merge(BugBunny.configuration.exchange_options || {})
69
+ .merge(exchange_opts || {})
70
+ final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
71
+ .merge(BugBunny.configuration.queue_options || {})
72
+ .merge(queue_opts || {})
73
+
74
+ BugBunny.configuration.logger.info("[BugBunny::Consumer] 🎧 Listening on '#{queue_name}' (Opts: #{final_q_opts})")
75
+ BugBunny.configuration.logger.info("[BugBunny::Consumer] 🔀 Bounded to Exchange '#{exchange_name}' (#{exchange_type}) | Opts: #{final_x_opts} | RK: '#{routing_key}'")
76
+
65
77
  start_health_check(queue_name)
66
78
 
67
79
  q.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
@@ -7,99 +7,146 @@ require 'active_support/core_ext/class/attribute'
7
7
  module BugBunny
8
8
  # Clase base para todos los Controladores de Mensajes en BugBunny.
9
9
  #
10
+ # Actúa como el receptor final de los mensajes enrutados desde el consumidor.
11
+ # Implementa un ciclo de vida similar a ActionController en Rails, soportando:
12
+ # - Filtros (`before_action`, `around_action`).
13
+ # - Manejo declarativo de errores (`rescue_from`).
14
+ # - Parsing de parámetros unificados (`params`).
15
+ # - Respuestas estructuradas (`render`).
16
+ #
10
17
  # @author Gabriel
11
18
  # @since 3.0.6
12
19
  class Controller
13
20
  include ActiveModel::Model
14
21
  include ActiveModel::Attributes
15
22
 
16
- # @return [Hash] Metadatos del mensaje entrante.
23
+ # @!group Atributos de Instancia
24
+
25
+ # @return [Hash] Metadatos del mensaje entrante (ej. HTTP method, routing_key, id).
17
26
  attribute :headers
18
27
 
19
- # @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados.
28
+ # @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados (Body JSON + Query String).
20
29
  attribute :params
21
30
 
22
- # @return [String] Cuerpo crudo.
31
+ # @return [String] Cuerpo crudo original en caso de no ser JSON.
23
32
  attribute :raw_string
24
33
 
25
- # @return [Hash] Headers de respuesta.
34
+ # @return [Hash] Headers de respuesta que serán enviados de vuelta en RPC.
26
35
  attr_reader :response_headers
27
36
 
28
- # @return [Hash, nil] Respuesta renderizada.
37
+ # @return [Hash, nil] Respuesta final renderizada.
29
38
  attr_reader :rendered_response
30
39
 
31
- # --- INFRAESTRUCTURA DE FILTROS (DEFINICIÓN) ---
32
- # Deben definirse ANTES de ser usados por la configuración de logs.
40
+ # @!endgroup
33
41
 
34
- # @api private
35
- def self.before_actions
36
- @before_actions ||= Hash.new { |h, k| h[k] = [] }
37
- end
38
42
 
39
- # @api private
40
- def self.around_actions
41
- @around_actions ||= Hash.new { |h, k| h[k] = [] }
42
- end
43
+ # ==========================================
44
+ # INFRAESTRUCTURA DE FILTROS Y LOGS (HEREDABLES)
45
+ # ==========================================
46
+
47
+ # Usamos `class_attribute` con `default` para garantizar la herencia correcta
48
+ # hacia las subclases (ej. de ApplicationController a ServicesController).
49
+ class_attribute :before_actions, default: {}
50
+ class_attribute :around_actions, default: {}
51
+ class_attribute :log_tags, default: []
52
+ class_attribute :rescue_handlers, default: []
43
53
 
44
54
  # Registra un filtro que se ejecutará **antes** de la acción.
55
+ # Si el filtro invoca `render`, la cadena se interrumpe y la acción no se ejecuta.
56
+ #
57
+ # @param method_name [Symbol] Nombre del método privado a ejecutar.
58
+ # @param options [Hash] Opciones como `only: [:show, :update]`.
59
+ # @return [void]
45
60
  def self.before_action(method_name, **options)
46
- register_callback(before_actions, method_name, options)
61
+ register_callback(:before_actions, method_name, options)
47
62
  end
48
63
 
49
64
  # Registra un filtro que **envuelve** la ejecución de la acción.
65
+ # El método registrado debe invocar `yield` para continuar la ejecución.
66
+ #
67
+ # @param method_name [Symbol] Nombre del método privado a ejecutar.
68
+ # @param options [Hash] Opciones como `only: [:index]`.
69
+ # @return [void]
50
70
  def self.around_action(method_name, **options)
51
- register_callback(around_actions, method_name, options)
71
+ register_callback(:around_actions, method_name, options)
52
72
  end
53
73
 
54
- # Helper interno para registrar callbacks.
55
- def self.register_callback(collection, method_name, options)
56
- only = Array(options[:only]).map(&:to_sym)
57
- target_actions = only.empty? ? [:_all_actions] : only
58
- target_actions.each { |action| collection[action] << method_name }
74
+ # Manejo declarativo de excepciones.
75
+ # Atrapa errores específicos que ocurran durante la ejecución de la acción.
76
+ #
77
+ # @example
78
+ # rescue_from Api::Error::NotFound, with: :render_not_found
79
+ # rescue_from StandardError do |e|
80
+ # render status: 500, json: { error: e.message }
81
+ # end
82
+ #
83
+ # @param klasses [Array<Class, String>] Clases de excepciones a atrapar.
84
+ # @param with [Symbol, nil] Nombre del método manejador.
85
+ # @yield [Exception] Bloque opcional para manejar el error inline.
86
+ # @raise [ArgumentError] Si no se provee un manejador (with o block).
87
+ def self.rescue_from(*klasses, with: nil, &block)
88
+ handler = with || block
89
+ raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
90
+
91
+ # Duplicamos el array del padre para no mutarlo al registrar reglas en el hijo
92
+ new_handlers = self.rescue_handlers.dup
93
+
94
+ klasses.each do |klass|
95
+ new_handlers.unshift([klass, handler])
96
+ end
97
+
98
+ self.rescue_handlers = new_handlers
59
99
  end
60
100
 
61
- # --- CONFIGURACIÓN DE LOGGING ---
101
+ # Helper interno para registrar callbacks garantizando Thread-Safety e Inmutabilidad del padre.
102
+ # @api private
103
+ def self.register_callback(collection_name, method_name, options)
104
+ current_hash = send(collection_name)
62
105
 
63
- # Define los tags que se antepondrán a cada línea de log.
64
- class_attribute :log_tags
65
- self.log_tags = []
106
+ # Deep dup: Clonamos el hash y sus arrays internos para no modificar la clase padre
107
+ new_hash = current_hash.transform_values(&:dup)
66
108
 
67
- # AHORA SÍ: Podemos llamar a around_action porque ya fue definido arriba.
68
- around_action :apply_log_tags
109
+ only = Array(options[:only]).map(&:to_sym)
110
+ target_actions = only.empty? ? [:_all_actions] : only
69
111
 
70
- # --- INICIALIZACIÓN ---
112
+ target_actions.each do |action|
113
+ new_hash[action] ||= []
114
+ new_hash[action] << method_name
115
+ end
71
116
 
72
- def initialize(attributes = {})
73
- super
74
- @response_headers = {}
117
+ send("#{collection_name}=", new_hash)
75
118
  end
76
119
 
77
- # --- MANEJO DE ERRORES ---
120
+ # Aplicamos automáticamente las etiquetas de logs a todas las acciones.
121
+ around_action :apply_log_tags
78
122
 
79
- # @api private
80
- def self.rescue_handlers
81
- @rescue_handlers ||= []
82
- end
83
123
 
84
- def self.rescue_from(*klasses, with: nil, &block)
85
- handler = with || block
86
- raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
124
+ # ==========================================
125
+ # INICIALIZACIÓN Y CICLO DE VIDA
126
+ # ==========================================
87
127
 
88
- klasses.each do |klass|
89
- rescue_handlers.unshift([klass, handler])
90
- end
128
+ def initialize(attributes = {})
129
+ super
130
+ @response_headers = {}
91
131
  end
92
132
 
93
- # --- PIPELINE DE EJECUCIÓN ---
94
-
133
+ # Punto de entrada principal estático llamado por el Router (`BugBunny::Consumer`).
134
+ #
135
+ # @param headers [Hash] Metadatos y variables de enrutamiento.
136
+ # @param body [String, Hash] El payload del mensaje AMQP.
137
+ # @return [Hash] Respuesta final estructurada.
95
138
  def self.call(headers:, body: {})
96
139
  new(headers: headers).process(body)
97
140
  end
98
141
 
142
+ # Ejecuta el ciclo de vida completo de la petición: Params -> Before -> Action -> Rescue.
143
+ #
144
+ # @param body [String, Hash] El cuerpo del mensaje.
145
+ # @return [Hash] La respuesta lista para ser enviada vía RabbitMQ RPC.
99
146
  def process(body)
100
147
  prepare_params(body)
101
148
 
102
- # Inyección de configuración global de logs si no hay específica
149
+ # Inyección de configuración global de logs si el controlador no define propios
103
150
  if self.class.log_tags.empty? && BugBunny.configuration.log_tags.any?
104
151
  self.class.log_tags = BugBunny.configuration.log_tags
105
152
  end
@@ -118,15 +165,14 @@ module BugBunny
118
165
  end
119
166
  end
120
167
 
121
- # Construir la cadena de responsabilidad
168
+ # Construir e invocar la cadena de responsabilidad (Middlewares/Around Actions)
122
169
  execution_chain = current_arounds.reverse.inject(core_execution) do |next_step, method_name|
123
170
  lambda { send(method_name, &next_step) }
124
171
  end
125
172
 
126
- # Ejecutar la cadena
127
173
  execution_chain.call
128
174
 
129
- # Respuesta final
175
+ # Si no hubo renderización explícita, devuelve 204 No Content
130
176
  rendered_response || { status: 204, headers: response_headers, body: nil }
131
177
 
132
178
  rescue StandardError => e
@@ -135,21 +181,14 @@ module BugBunny
135
181
 
136
182
  private
137
183
 
138
- # --- HELPERS INTERNOS ---
139
-
140
- def resolve_callbacks(collection, action_name)
141
- (collection[:_all_actions] || []) + (collection[action_name] || [])
142
- end
143
-
144
- def run_before_actions(action_name)
145
- current_befores = resolve_callbacks(self.class.before_actions, action_name)
146
- current_befores.uniq.each do |method_name|
147
- send(method_name)
148
- return false if rendered_response
149
- end
150
- true
151
- end
184
+ # ==========================================
185
+ # HELPERS INTERNOS
186
+ # ==========================================
152
187
 
188
+ # Evalúa la excepción lanzada y busca el manejador más adecuado definido en `rescue_from`.
189
+ #
190
+ # @param exception [StandardError] La excepción atrapada.
191
+ # @return [Hash] Respuesta de error renderizada.
153
192
  def handle_exception(exception)
154
193
  handler_entry = self.class.rescue_handlers.find do |klass, _|
155
194
  if klass.is_a?(String)
@@ -161,24 +200,34 @@ module BugBunny
161
200
 
162
201
  if handler_entry
163
202
  _, handler = handler_entry
164
- if handler.is_a?(Symbol); send(handler, exception)
165
- elsif handler.respond_to?(:call); instance_exec(exception, &handler)
203
+ if handler.is_a?(Symbol)
204
+ send(handler, exception)
205
+ elsif handler.respond_to?(:call)
206
+ instance_exec(exception, &handler)
166
207
  end
167
208
  return rendered_response if rendered_response
168
209
  end
169
210
 
211
+ # Fallback genérico si la excepción no fue mapeada
170
212
  BugBunny.configuration.logger.error("[BugBunny::Controller] 💥 Unhandled Exception (#{exception.class}): #{exception.message}")
171
- BugBunny.configuration.logger.error(exception.backtrace.first(5).join("\n")) # Limitamos a 5 líneas para no ensuciar
213
+ BugBunny.configuration.logger.error(exception.backtrace.first(5).join("\n"))
172
214
 
173
215
  {
174
216
  status: 500,
175
217
  headers: response_headers,
176
- body: { error: exception.message, type: exception.class.name }
218
+ body: { error: "Internal Server Error", detail: exception.message, type: exception.class.name }
177
219
  }
178
220
  end
179
221
 
222
+ # Renderiza una respuesta que será enviada de vuelta por la cola reply-to.
223
+ #
224
+ # @param status [Symbol, Integer] Código HTTP (ej. :ok, :not_found, 201).
225
+ # @param json [Object] El payload a serializar como JSON.
226
+ # @return [Hash] La estructura renderizada interna.
180
227
  def render(status:, json: nil)
181
- code = Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || 200
228
+ code = Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || status.to_i
229
+ code = 200 if code.zero? # Fallback de seguridad
230
+
182
231
  @rendered_response = {
183
232
  status: code,
184
233
  headers: response_headers,
@@ -186,21 +235,43 @@ module BugBunny
186
235
  }
187
236
  end
188
237
 
238
+ # Unifica el query string, parámetros de ruta y el body JSON en un solo objeto `params`.
189
239
  def prepare_params(body)
190
240
  self.params = {}.with_indifferent_access
241
+
191
242
  params.merge!(headers[:query_params]) if headers[:query_params].present?
192
243
  params[:id] = headers[:id] if headers[:id].present?
193
244
 
194
245
  if body.is_a?(Hash)
195
246
  params.merge!(body)
196
247
  elsif body.is_a?(String) && headers[:content_type].to_s.include?('json')
197
- parsed = JSON.parse(body) rescue nil
248
+ parsed = begin
249
+ JSON.parse(body)
250
+ rescue JSON::ParserError
251
+ nil
252
+ end
198
253
  params.merge!(parsed) if parsed
199
254
  else
200
255
  self.raw_string = body
201
256
  end
202
257
  end
203
258
 
259
+ # Obtiene la lista combinada de callbacks globales y específicos para una acción.
260
+ def resolve_callbacks(collection, action_name)
261
+ (collection[:_all_actions] || []) + (collection[action_name] || [])
262
+ end
263
+
264
+ # Ejecuta secuencialmente todos los before_actions.
265
+ # Si alguno invoca render(), detiene el flujo devolviendo `false`.
266
+ def run_before_actions(action_name)
267
+ current_befores = resolve_callbacks(self.class.before_actions, action_name)
268
+ current_befores.uniq.each do |method_name|
269
+ send(method_name)
270
+ return false if rendered_response
271
+ end
272
+ true
273
+ end
274
+
204
275
  # --- LÓGICA DE LOGGING ENCAPSULADA ---
205
276
 
206
277
  def apply_log_tags
@@ -225,6 +296,7 @@ module BugBunny
225
296
  end.compact
226
297
  end
227
298
 
299
+ # @return [String] Identificador único de trazabilidad de la petición.
228
300
  def uuid
229
301
  headers[:correlation_id] || headers['X-Request-Id']
230
302
  end
@@ -1,4 +1,5 @@
1
- # lib/bug_bunny/exception.rb
1
+ # frozen_string_literal: true
2
+
2
3
  require 'json'
3
4
 
4
5
  module BugBunny
@@ -11,10 +12,12 @@ module BugBunny
11
12
  class CommunicationError < Error; end
12
13
 
13
14
  # Error lanzado cuando ocurren un acceso no permitido a controladores.
14
- # Suele envolver excepciones nativas de la gema `bunny` (ej: TCP connection failure).
15
+ # Protege contra vulnerabilidades de RCE validando la herencia de las clases enrutadas.
15
16
  class SecurityError < Error; end
16
17
 
17
- # === Categoría: Errores del Cliente (4xx) ===
18
+ # ==========================================
19
+ # Categoría: Errores del Cliente (4xx)
20
+ # ==========================================
18
21
 
19
22
  # Clase base para errores causados por una petición incorrecta del cliente.
20
23
  # Corresponde a códigos de estado 400-499.
@@ -36,7 +39,13 @@ module BugBunny
36
39
  # El servidor tardó demasiado en responder o el cliente agotó su tiempo de espera (RPC timeout).
37
40
  class RequestTimeout < ClientError; end
38
41
 
39
- # === Categoría: Errores del Servidor (5xx) ===
42
+ # Error 409: Conflict.
43
+ # implica que la petición es técnicamente válida, pero choca con reglas de negocio o datos existentes
44
+ class Conflict < ClientError; end
45
+
46
+ # ==========================================
47
+ # Categoría: Errores del Servidor (5xx)
48
+ # ==========================================
40
49
 
41
50
  # Clase base para errores causados por fallos en el servidor remoto.
42
51
  # Corresponde a códigos de estado 500-599.
@@ -46,44 +55,59 @@ module BugBunny
46
55
  # Ocurrió un error inesperado en el worker/servidor remoto al procesar el mensaje.
47
56
  class InternalServerError < ServerError; end
48
57
 
49
- # === Categoría: Errores de Validación (422) ===
58
+ # ==========================================
59
+ # Categoría: Errores de Validación (422)
60
+ # ==========================================
50
61
 
51
62
  # Error 422: Unprocessable Entity.
52
63
  # Indica que la solicitud fue bien formada pero contenía errores semánticos,
53
64
  # típicamente fallos de validación en el modelo remoto (ActiveRecord).
54
65
  #
55
- # Esta excepción es especial porque intenta parsear automáticamente el cuerpo de la respuesta
56
- # para exponer los mensajes de error de forma estructurada.
66
+ # Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
67
+ # de la respuesta para extraer y exponer los mensajes de error de forma estructurada,
68
+ # buscando por convención la clave `errors`.
57
69
  class UnprocessableEntity < ClientError
58
- # @return [Hash, Array] Los mensajes de error parseados desde la respuesta.
70
+ # @return [Hash, Array, String] Los mensajes de error listos para ser iterados.
59
71
  attr_reader :error_messages
60
72
 
61
- # @return [String] El cuerpo crudo de la respuesta original.
73
+ # @return [String, Hash] El cuerpo crudo de la respuesta original.
62
74
  attr_reader :raw_response
63
75
 
64
76
  # Inicializa la excepción procesando el cuerpo de la respuesta.
65
77
  #
66
- # @param response_body [String, Hash] El cuerpo de la respuesta fallida.
78
+ # @param response_body [String, Hash] El cuerpo de la respuesta fallida (ej. `{ "errors": { "name": ["blank"] } }`).
67
79
  def initialize(response_body)
68
80
  @raw_response = response_body
69
- @error_messages = parse_errors(response_body)
81
+ @error_messages = extract_errors(response_body)
70
82
  super('Validation failed on remote service')
71
83
  end
72
84
 
73
85
  private
74
86
 
75
- # Intenta convertir el cuerpo de la respuesta a una estructura Ruby (Hash/Array).
76
- # Si el cuerpo no es JSON válido, retorna un Hash vacío para evitar excepciones anidadas.
87
+ # Intenta convertir el cuerpo de la respuesta a una estructura Ruby y extrae la clave 'errors'.
88
+ # Si el cuerpo no sigue la convención o no es JSON, hace un graceful fallback devolviendo
89
+ # el payload completo.
77
90
  #
78
- # @param body [String, Hash] El cuerpo a parsear.
79
- # @return [Object] El cuerpo parseado o un Hash vacío si falla.
91
+ # @param body [String, Hash] El cuerpo a procesar.
92
+ # @return [Object] Los errores aislados o el cuerpo original.
80
93
  # @api private
81
- def parse_errors(body)
82
- return body if body.is_a?(Hash)
83
-
84
- JSON.parse(body)
85
- rescue JSON::ParserError
86
- {}
94
+ def extract_errors(body)
95
+ parsed = if body.is_a?(String)
96
+ begin
97
+ JSON.parse(body)
98
+ rescue JSON::ParserError
99
+ body # Si no es JSON, devolvemos el string tal cual
100
+ end
101
+ else
102
+ body
103
+ end
104
+
105
+ if parsed.is_a?(Hash)
106
+ # Extraemos inteligentemente la clave 'errors' si existe (convención típica de Rails)
107
+ parsed['errors'] || parsed[:errors] || parsed
108
+ else
109
+ parsed
110
+ end
87
111
  end
88
112
  end
89
113
  end
@@ -1,22 +1,33 @@
1
- # lib/bug_bunny/middleware/raise_error.rb
2
1
  # frozen_string_literal: true
3
2
 
4
- require_relative '../middleware/base'
3
+ require_relative 'base'
5
4
 
6
5
  module BugBunny
7
6
  module Middleware
8
7
  # Middleware que inspecciona el status de la respuesta y lanza excepciones
9
8
  # si se encuentran errores (4xx o 5xx).
10
9
  #
10
+ # Extrae inteligentemente el mensaje de error del cuerpo de la respuesta
11
+ # para que las excepciones tengan trazas claras y legibles, evitando el
12
+ # output crudo de Hashes en Ruby (`{ "error" => ... }`).
13
+ #
11
14
  # @see BugBunny::Middleware::Base
12
15
  class RaiseError < BugBunny::Middleware::Base
13
16
  # Hook de ciclo de vida: Ejecutado después de recibir la respuesta.
14
17
  #
15
- # Verifica el código de estado y lanza la excepción correspondiente.
18
+ # Verifica el código de estado (status) de la respuesta. Si cae en el rango
19
+ # de éxito (2xx), permite que el flujo continúe. Si es un error, lo formatea
20
+ # y lanza la excepción semántica correspondiente.
16
21
  #
17
22
  # @param response [Hash] El hash de respuesta conteniendo 'status' y 'body'.
18
- # @raise [BugBunny::ClientError] Si el status es 4xx.
19
- # @raise [BugBunny::ServerError] Si el status es 5xx.
23
+ # @raise [BugBunny::BadRequest] Si el status es 400.
24
+ # @raise [BugBunny::NotFound] Si el status es 404.
25
+ # @raise [BugBunny::NotAcceptable] Si el status es 406.
26
+ # @raise [BugBunny::RequestTimeout] Si el status es 408.
27
+ # @raise [BugBunny::Conflict] Si el status es 409.
28
+ # @raise [BugBunny::UnprocessableEntity] Si el status es 422.
29
+ # @raise [BugBunny::InternalServerError] Si el status es 500..599.
30
+ # @raise [BugBunny::ClientError, BugBunny::ServerError] Para códigos no mapeados.
20
31
  # @return [void]
21
32
  def on_complete(response)
22
33
  status = response['status'].to_i
@@ -24,25 +35,64 @@ module BugBunny
24
35
 
25
36
  case status
26
37
  when 200..299
27
- nil # OK
28
- when 400 then raise BugBunny::BadRequest, body
29
- when 404 then raise BugBunny::NotFound
30
- when 406 then raise BugBunny::NotAcceptable
31
- when 408 then raise BugBunny::RequestTimeout
32
- when 422 then raise BugBunny::UnprocessableEntity, body
33
- when 500 then raise BugBunny::InternalServerError, body
38
+ return # Flujo normal (Success)
39
+ when 400
40
+ raise BugBunny::BadRequest, format_error_message(body)
41
+ when 404
42
+ raise BugBunny::NotFound
43
+ when 406
44
+ raise BugBunny::NotAcceptable
45
+ when 408
46
+ raise BugBunny::RequestTimeout
47
+ when 409
48
+ raise BugBunny::Conflict, format_error_message(body)
49
+ when 422
50
+ # Pasamos el body crudo; UnprocessableEntity lo procesará en exception.rb
51
+ raise BugBunny::UnprocessableEntity, body
52
+ when 500..599
53
+ raise BugBunny::InternalServerError, format_error_message(body)
34
54
  else
35
- handle_unknown_error(status)
55
+ handle_unknown_error(status, body)
36
56
  end
37
57
  end
38
58
 
39
59
  private
40
60
 
41
- # Maneja errores 4xx genéricos no mapeados explícitamente.
42
- # @param status [Integer] El código de estado HTTP.
43
- # @raise [BugBunny::ClientError] Siempre lanza esta excepción.
44
- def handle_unknown_error(status)
45
- raise BugBunny::ClientError, "Unknown error: #{status}" if status >= 400
61
+ # Formatea el cuerpo de la respuesta de error para que sea legible en las excepciones.
62
+ #
63
+ # Prioriza la convención `{ "error": "...", "detail": "..." }`. Si la respuesta no
64
+ # sigue esta convención, convierte el Hash completo a un JSON string para mantenerlo legible.
65
+ #
66
+ # @param body [Hash, String, nil] El cuerpo de la respuesta.
67
+ # @return [String] Un mensaje de error limpio y estructurado.
68
+ def format_error_message(body)
69
+ return "Unknown Error" if body.nil? || (body.respond_to?(:empty?) && body.empty?)
70
+ return body if body.is_a?(String)
71
+
72
+ # Si el worker devolvió un JSON con una key 'error' (nuestra convención en Controller)
73
+ if body.is_a?(Hash) && body['error']
74
+ detail = body['detail'] ? " - #{body['detail']}" : ""
75
+ "#{body['error']}#{detail}"
76
+ else
77
+ # Fallback: Convertir todo el Hash a JSON string para que se vea claro en Sentry/Logs
78
+ body.to_json
79
+ end
80
+ end
81
+
82
+ # Maneja códigos de error genéricos no mapeados explícitamente en el `case`.
83
+ #
84
+ # @param status [Integer] El código de estado HTTP (ej. 418, 502).
85
+ # @param body [Object] El cuerpo crudo de la respuesta.
86
+ # @raise [BugBunny::ServerError] Si es 5xx.
87
+ # @raise [BugBunny::ClientError] Si es 4xx.
88
+ def handle_unknown_error(status, body)
89
+ msg = format_error_message(body)
90
+
91
+ if status >= 500
92
+ raise BugBunny::ServerError, "Server Error (#{status}): #{msg}"
93
+ elsif status >= 400
94
+ raise BugBunny::ClientError, "Client Error (#{status}): #{msg}"
95
+ end
46
96
  end
47
97
  end
48
98
  end
@@ -91,16 +91,26 @@ module BugBunny
91
91
 
92
92
  private
93
93
 
94
+ # Registra la petición en el log calculando las opciones de infraestructura.
95
+ #
96
+ # @param request [BugBunny::Request] Objeto Request que se está enviando.
97
+ # @param payload [String] El cuerpo del mensaje serializado.
94
98
  def log_request(request, payload)
95
99
  verb = request.method.to_s.upcase
96
100
  target = request.path
97
101
  rk = request.final_routing_key
98
102
  id = request.correlation_id
99
103
 
104
+ # 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
105
+ final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
106
+ .merge(BugBunny.configuration.exchange_options || {})
107
+ .merge(request.exchange_options || {})
108
+
100
109
  # INFO: Resumen de una línea (Traffic)
101
110
  BugBunny.configuration.logger.info("[BugBunny::Producer] 📤 #{verb} /#{target} | RK: '#{rk}' | ID: #{id}")
102
111
 
103
- # DEBUG: Detalle completo (Payload)
112
+ # DEBUG: Detalle completo de Infraestructura y Payload
113
+ BugBunny.configuration.logger.debug("[BugBunny::Producer] ⚙️ Exchange Opts: #{final_x_opts}")
104
114
  BugBunny.configuration.logger.debug("[BugBunny::Producer] 📦 Payload: #{payload.truncate(300)}") if payload.is_a?(String)
105
115
  end
106
116
 
@@ -15,7 +15,7 @@ module BugBunny
15
15
  # 3. **Específico:** Definidos en la clase del recurso o vía `with`.
16
16
  #
17
17
  # @author Gabriel
18
- # @since 3.1.0
18
+ # @since 3.1.2
19
19
  class Resource
20
20
  include ActiveModel::API
21
21
  include ActiveModel::Attributes
@@ -100,13 +100,22 @@ module BugBunny
100
100
  stack
101
101
  end
102
102
 
103
- # Instancia el cliente inyectando los middlewares configurados.
103
+ # Instancia el cliente inyectando los middlewares núcleo y personalizados.
104
+ # Integra automáticamente `RaiseError` y `JsonResponse` para que el ORM trabaje
105
+ # puramente con datos parseados o atrape excepciones sin validar HTTP Status manuales.
106
+ #
104
107
  # @return [BugBunny::Client]
105
108
  def bug_bunny_client
106
109
  pool = connection_pool
107
110
  raise BugBunny::Error, "Connection pool missing for #{name}" unless pool
108
- BugBunny::Client.new(pool: pool) do |conn|
109
- resolve_middleware_stack.each { |block| block.call(conn) }
111
+
112
+ BugBunny::Client.new(pool: pool) do |stack|
113
+ # 1. Middlewares Core (Siempre presentes para el Resource)
114
+ stack.use BugBunny::Middleware::RaiseError
115
+ stack.use BugBunny::Middleware::JsonResponse
116
+
117
+ # 2. Middlewares Personalizados del Usuario
118
+ resolve_middleware_stack.each { |block| block.call(stack) }
110
119
  end
111
120
  end
112
121
 
@@ -164,6 +173,8 @@ module BugBunny
164
173
  # @!group Acciones CRUD RESTful
165
174
 
166
175
  # Realiza una búsqueda filtrada (GET).
176
+ # Mapea un posible 404 a un array vacío.
177
+ #
167
178
  # @param filters [Hash]
168
179
  # @return [Array<BugBunny::Resource>]
169
180
  def where(filters = {})
@@ -188,6 +199,8 @@ module BugBunny
188
199
  inst.send(:clear_changes_information)
189
200
  inst
190
201
  end
202
+ rescue BugBunny::NotFound
203
+ []
191
204
  end
192
205
 
193
206
  # Devuelve todos los registros.
@@ -195,6 +208,8 @@ module BugBunny
195
208
  def all; where({}); end
196
209
 
197
210
  # Busca un registro por ID (GET).
211
+ # Mapea un 404 (NotFound) devolviendo un objeto nulo.
212
+ #
198
213
  # @param id [String, Integer]
199
214
  # @return [BugBunny::Resource, nil]
200
215
  def find(id)
@@ -211,12 +226,14 @@ module BugBunny
211
226
  queue_options: current_queue_options
212
227
  )
213
228
 
214
- return nil if response.nil? || response['status'] == 404
215
- return nil unless response['body'].is_a?(Hash)
229
+ return nil unless response && response['body'].is_a?(Hash)
230
+
216
231
  instance = new(response['body'])
217
232
  instance.persisted = true
218
233
  instance.send(:clear_changes_information)
219
234
  instance
235
+ rescue BugBunny::NotFound
236
+ nil
220
237
  end
221
238
 
222
239
  # Crea una nueva instancia y la persiste.
@@ -350,7 +367,9 @@ module BugBunny
350
367
  # @!group Persistencia
351
368
 
352
369
  # Guarda el recurso en el servidor remoto vía AMQP (POST o PUT).
353
- # @return [Boolean]
370
+ # Asume el Happy Path; el middleware se encarga de interceptar y lanzar excepciones.
371
+ #
372
+ # @return [Boolean] Retorna true si tuvo éxito, false si falló la validación.
354
373
  def save
355
374
  return false unless valid?
356
375
 
@@ -364,6 +383,7 @@ module BugBunny
364
383
  path = is_new ? self.class.resource_name : "#{self.class.resource_name}/#{id}"
365
384
  method = is_new ? :post : :put
366
385
 
386
+ # Si el middleware de errores no lanza excepción, asumimos un éxito (200..299)
367
387
  response = bug_bunny_client.request(
368
388
  path,
369
389
  method: method,
@@ -375,7 +395,10 @@ module BugBunny
375
395
  body: wrapped_payload
376
396
  )
377
397
 
378
- handle_save_response(response)
398
+ assign_attributes(response['body'])
399
+ self.persisted = true
400
+ clear_changes_information
401
+ true
379
402
  end
380
403
  rescue BugBunny::UnprocessableEntity => e
381
404
  load_remote_rabbit_errors(e.error_messages)
@@ -383,6 +406,7 @@ module BugBunny
383
406
  end
384
407
 
385
408
  # Elimina el recurso del servidor remoto (DELETE).
409
+ #
386
410
  # @return [Boolean]
387
411
  def destroy
388
412
  return false unless persisted?
@@ -409,25 +433,9 @@ module BugBunny
409
433
 
410
434
  private
411
435
 
412
- # Maneja la lógica de respuesta para la acción de guardado.
413
- def handle_save_response(response)
414
- if response['status'] == 422
415
- raise BugBunny::UnprocessableEntity.new(response['body']['errors'] || response['body'])
416
- elsif response['status'] >= 500
417
- raise BugBunny::InternalServerError
418
- elsif response['status'] >= 400
419
- raise BugBunny::ClientError
420
- end
421
-
422
- assign_attributes(response['body'])
423
- self.persisted = true
424
- clear_changes_information
425
- true
426
- end
427
-
428
- # Carga errores remotos en el objeto local.
436
+ # Carga errores remotos en el objeto local (utilizado al recibir 422).
429
437
  def load_remote_rabbit_errors(errors_hash)
430
- return if errors_hash.nil?
438
+ return if errors_hash.nil? || errors_hash.empty?
431
439
  if errors_hash.is_a?(String)
432
440
  errors.add(:base, errors_hash)
433
441
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "3.1.1"
4
+ VERSION = "3.1.3"
5
5
  end
data/test/test_helper.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'bundler/setup'
4
4
  require 'minitest/autorun'
5
- # require 'minitest/reporters'
5
+ # require 'minitest/reporters'
6
6
  require 'bug_bunny'
7
7
  require 'connection_pool'
8
8
  require 'securerandom'
@@ -14,7 +14,7 @@ BugBunny.configure do |config|
14
14
  config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
15
15
  config.vhost = '/'
16
16
  config.logger = Logger.new($stdout)
17
- config.logger.level = Logger::WARN
17
+ config.logger.level = Logger::WARN
18
18
 
19
19
  # ========================================================
20
20
  # LA MAGIA DE LA CASCADA (Nivel 2: Configuración Global)
@@ -38,10 +38,10 @@ module IntegrationHelper
38
38
 
39
39
  def with_running_worker(queue:, exchange:, exchange_type: 'topic', routing_key: '#')
40
40
  conn = BugBunny.create_connection
41
-
41
+
42
42
  worker_thread = Thread.new do
43
43
  ch = conn.create_channel
44
-
44
+
45
45
  x_opts = BugBunny.configuration.exchange_options || {}
46
46
  q_opts = BugBunny.configuration.queue_options || {}
47
47
 
@@ -63,7 +63,7 @@ module IntegrationHelper
63
63
  puts e.backtrace.join("\n")
64
64
  end
65
65
 
66
- sleep 0.5
66
+ sleep 0.5
67
67
  yield
68
68
  ensure
69
69
  conn&.close
@@ -74,26 +74,26 @@ module IntegrationHelper
74
74
  def with_spy_worker(queue:, exchange:, exchange_type: 'topic', routing_key: '#')
75
75
  captured_messages = Thread::Queue.new
76
76
  conn = BugBunny.create_connection
77
-
77
+
78
78
  worker_thread = Thread.new do
79
79
  ch = conn.create_channel
80
-
80
+
81
81
  x_opts = BugBunny.configuration.exchange_options || {}
82
82
  q_opts = BugBunny.configuration.queue_options || {}
83
-
83
+
84
84
  # FIX DEFINITIVO: Ahora 'x' es un hermoso objeto Bunny::Exchange
85
85
  # que la función .bind() entiende perfectamente.
86
86
  x = ch.public_send(exchange_type, exchange, x_opts)
87
87
  q = ch.queue(queue, q_opts)
88
-
88
+
89
89
  # Bindeamos el objeto al queue
90
90
  q.bind(x, routing_key: routing_key)
91
91
 
92
92
  q.subscribe(block: true) do |delivery, props, body|
93
- captured_messages << {
94
- body: body,
93
+ captured_messages << {
94
+ body: body,
95
95
  routing_key: delivery.routing_key,
96
- headers: props.headers
96
+ headers: props.headers
97
97
  }
98
98
  end
99
99
  rescue => e
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix