bug_bunny 3.1.0 → 3.1.2

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.
@@ -1,14 +1,16 @@
1
- # lib/bug_bunny/client.rb
1
+ # frozen_string_literal: true
2
+
2
3
  require_relative 'middleware/stack'
3
4
 
4
5
  module BugBunny
5
6
  # Cliente principal para realizar peticiones a RabbitMQ.
6
7
  #
7
8
  # Implementa el patrón "Onion Middleware" (Arquitectura de Cebolla) similar a Faraday.
8
- # Mantiene una interfaz flexible donde el verbo HTTP se pasa como opción.
9
+ # Mantiene una interfaz flexible donde el verbo HTTP se pasa como opción y permite
10
+ # configurar la infraestructura AMQP de forma granular por petición.
9
11
  #
10
- # @example Petición RPC (GET)
11
- # client.request('users/123', method: :get)
12
+ # @example Petición RPC (GET) con opciones de infraestructura
13
+ # client.request('users/123', method: :get, exchange_options: { durable: true })
12
14
  #
13
15
  # @example Publicación Fire-and-Forget (POST)
14
16
  # client.publish('logs', method: :post, body: { msg: 'Error' })
@@ -41,6 +43,8 @@ module BugBunny
41
43
  # @option args [Object] :body El cuerpo del mensaje.
42
44
  # @option args [Hash] :headers Headers AMQP adicionales.
43
45
  # @option args [Integer] :timeout Tiempo máximo de espera.
46
+ # @option args [Hash] :exchange_options Opciones específicas para la declaración del Exchange.
47
+ # @option args [Hash] :queue_options Opciones específicas para la declaración de la Cola.
44
48
  # @yield [req] Bloque para configurar el objeto Request directamente.
45
49
  # @return [Hash] La respuesta del servidor.
46
50
  def request(url, **args)
@@ -65,18 +69,28 @@ module BugBunny
65
69
 
66
70
  # Ejecuta la lógica de envío dentro del contexto del Pool.
67
71
  # Mapea los argumentos al objeto Request y ejecuta la cadena de middlewares.
72
+ #
73
+ # @param method_name [Symbol] El método del productor a llamar (:rpc o :fire).
74
+ # @param url [String] La ruta destino.
75
+ # @param args [Hash] Argumentos pasados a los métodos públicos.
76
+ # @yield [req] Bloque para configuración adicional del Request.
68
77
  def run_in_pool(method_name, url, args)
69
78
  # 1. Builder del Request
70
79
  req = BugBunny::Request.new(url)
71
80
 
72
81
  # 2. Syntactic Sugar: Mapeo de argumentos a atributos del Request
73
- req.method = args[:method] if args[:method]
74
- req.body = args[:body] if args[:body]
75
- req.exchange = args[:exchange] if args[:exchange]
76
- req.exchange_type = args[:exchange_type] if args[:exchange_type]
77
- req.routing_key = args[:routing_key] if args[:routing_key]
78
- req.timeout = args[:timeout] if args[:timeout]
79
- req.headers.merge!(args[:headers]) if args[:headers]
82
+ req.method = args[:method] if args[:method]
83
+ req.body = args[:body] if args[:body]
84
+ req.exchange = args[:exchange] if args[:exchange]
85
+ req.exchange_type = args[:exchange_type] if args[:exchange_type]
86
+ req.routing_key = args[:routing_key] if args[:routing_key]
87
+ req.timeout = args[:timeout] if args[:timeout]
88
+
89
+ # Inyección de opciones de infraestructura (Nivel 3 de la cascada)
90
+ req.exchange_options = args[:exchange_options] if args[:exchange_options]
91
+ req.queue_options = args[:queue_options] if args[:queue_options]
92
+
93
+ req.headers.merge!(args[:headers]) if args[:headers]
80
94
 
81
95
  # 3. Configuración del usuario (bloque específico por request)
82
96
  yield req if block_given?
@@ -94,6 +108,7 @@ module BugBunny
94
108
  app = @stack.build(final_action)
95
109
  app.call(req)
96
110
  ensure
111
+ # Aseguramos el cierre del canal pero mantenemos la conexión del pool
97
112
  session.close
98
113
  end
99
114
  end
@@ -4,7 +4,14 @@ require 'logger'
4
4
 
5
5
  module BugBunny
6
6
  # Clase de configuración global para la gema BugBunny.
7
- # Almacena las credenciales de conexión, timeouts y parámetros de ajuste de RabbitMQ.
7
+ # Almacena las credenciales de conexión, timeouts y parámetros de ajuste de RabbitMQ,
8
+ # así como las opciones por defecto para la declaración de infraestructura AMQP.
9
+ #
10
+ # @example Configuración en un inicializador (e.g., config/initializers/bug_bunny.rb)
11
+ # BugBunny.configure do |config|
12
+ # config.host = '127.0.0.1'
13
+ # config.exchange_options = { durable: true, auto_delete: false }
14
+ # end
8
15
  class Configuration
9
16
  # @return [String] Host o IP del servidor RabbitMQ (ej: 'localhost').
10
17
  attr_accessor :host
@@ -60,9 +67,23 @@ module BugBunny
60
67
  # @return [String] Namespace base donde se buscarán los controladores (default: 'Rabbit::Controllers').
61
68
  attr_accessor :controller_namespace
62
69
 
63
- # @return [Array<Symbol, Proc, String>]
70
+ # @return [Array<Symbol, Proc, String>] Etiquetas para el log estructurado.
64
71
  attr_accessor :log_tags
65
72
 
73
+ # @!group Configuración de Infraestructura Global
74
+
75
+ # @return [Hash] Opciones globales por defecto para la declaración de Exchanges.
76
+ # Estas opciones se fusionarán con los valores por defecto de la gema y las específicas del recurso.
77
+ # @example { durable: true, auto_delete: false }
78
+ attr_accessor :exchange_options
79
+
80
+ # @return [Hash] Opciones globales por defecto para la declaración de Colas.
81
+ # Estas opciones se fusionarán con los valores por defecto de la gema y las específicas del recurso.
82
+ # @example { durable: true, exclusive: false }
83
+ attr_accessor :queue_options
84
+
85
+ # @!endgroup
86
+
66
87
  # Inicializa la configuración con valores por defecto seguros.
67
88
  def initialize
68
89
  @host = '127.0.0.1'
@@ -91,9 +112,14 @@ module BugBunny
91
112
  @controller_namespace = 'Rabbit::Controllers'
92
113
 
93
114
  @log_tags = [:uuid]
115
+
116
+ # Inicialización de opciones de infraestructura como hashes vacíos para permitir fusiones posteriores.
117
+ @exchange_options = {}
118
+ @queue_options = {}
94
119
  end
95
120
 
96
121
  # Construye la URL de conexión AMQP basada en los atributos configurados.
122
+ # @return [String] URL formateada amqp://user:pass@host:port/vhost
97
123
  def url
98
124
  "amqp://#{username}:#{password}@#{host}:#{port}/#{vhost}"
99
125
  end
@@ -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("[Consumer] Listening on #{queue_name} (Exchange: #{exchange_name})")
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|
@@ -78,7 +90,7 @@ module BugBunny
78
90
  end
79
91
  end
80
92
  rescue StandardError => e
81
- BugBunny.configuration.logger.error("[Consumer] Connection Error: #{e.message}. Retrying...")
93
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Connection Error: #{e.message}. Retrying in #{BugBunny.configuration.network_recovery_interval}s...")
82
94
  sleep BugBunny.configuration.network_recovery_interval
83
95
  retry
84
96
  end
@@ -94,15 +106,11 @@ module BugBunny
94
106
  # @param body [String] El payload crudo del mensaje.
95
107
  # @return [void]
96
108
  def process_message(delivery_info, properties, body)
97
- BugBunny.configuration.logger.debug("delivery_info: #{delivery_info}, properties: #{properties}, body: #{body}")
98
- # 1. Recuperación Robusta del Path (Ruta)
99
- path = properties.type
100
- if path.nil? || path.empty?
101
- path = properties.headers ? properties.headers['path'] : nil
102
- end
109
+ # 1. Validación de Headers
110
+ path = properties.type || (properties.headers && properties.headers['path'])
103
111
 
104
112
  if path.nil? || path.empty?
105
- BugBunny.configuration.logger.error("[Consumer] Missing 'type' or 'path' header. Message rejected.")
113
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] ⛔ Rejected: Missing 'type' header.")
106
114
  session.channel.reject(delivery_info.delivery_tag, false)
107
115
  return
108
116
  end
@@ -114,6 +122,9 @@ module BugBunny
114
122
  # 3. Router: Inferencia de Controlador y Acción
115
123
  route_info = router_dispatch(http_method, path)
116
124
 
125
+ BugBunny.configuration.logger.info("[BugBunny::Consumer] 📥 Started #{http_method} \"/#{path}\" for Routing Key: #{delivery_info.routing_key}")
126
+ BugBunny.configuration.logger.debug("[BugBunny::Consumer] 📦 Body: #{body.truncate(200)}")
127
+
117
128
  request_metadata = {
118
129
  type: path,
119
130
  http_method: http_method,
@@ -133,7 +144,7 @@ module BugBunny
133
144
  controller_name = route_info[:controller].camelize
134
145
 
135
146
  # Construcción: "Messaging::Handlers" + "::" + "Users"
136
- controller_class_name = "#{namespace}::#{controller_name}"
147
+ controller_class_name = "#{namespace}::#{controller_name}Controller"
137
148
 
138
149
  controller_class = controller_class_name.constantize
139
150
 
@@ -141,7 +152,7 @@ module BugBunny
141
152
  raise BugBunny::SecurityError, "Class #{controller_class} is not a valid BugBunny Controller"
142
153
  end
143
154
  rescue NameError => _e
144
- BugBunny.configuration.logger.error("[Consumer] Controller not found: #{controller_class_name}")
155
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Controller not found: #{controller_class_name} (Path: #{path})")
145
156
  handle_fatal_error(properties, 404, "Not Found", "Controller #{controller_class_name} not found")
146
157
  session.channel.reject(delivery_info.delivery_tag, false)
147
158
  return
@@ -159,7 +170,7 @@ module BugBunny
159
170
  session.channel.ack(delivery_info.delivery_tag)
160
171
 
161
172
  rescue StandardError => e
162
- BugBunny.configuration.logger.error("[Consumer] Execution Error: #{e.message}")
173
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Execution Error (#{e.class}): #{e.message}")
163
174
  handle_fatal_error(properties, 500, "Internal Server Error", e.message)
164
175
  session.channel.reject(delivery_info.delivery_tag, false)
165
176
  end
@@ -216,7 +227,7 @@ module BugBunny
216
227
  # @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
217
228
  # @return [void]
218
229
  def reply(payload, reply_to, correlation_id)
219
- BugBunny.configuration.logger.debug("[Consumer] 📤 Enviando REPLY a: #{reply_to} | ID: #{correlation_id}")
230
+ BugBunny.configuration.logger.debug("[BugBunny::Consumer] 📤 Sending RPC Reply to #{reply_to} | ID: #{correlation_id}")
220
231
  session.channel.default_exchange.publish(
221
232
  payload.to_json,
222
233
  routing_key: reply_to,
@@ -247,7 +258,7 @@ module BugBunny
247
258
  Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
248
259
  session.channel.queue_declare(q_name, passive: true)
249
260
  rescue StandardError
250
- BugBunny.configuration.logger.warn("[Consumer] Queue check failed. Reconnecting session...")
261
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Queue check failed. Reconnecting session...")
251
262
  session.close
252
263
  end.execute
253
264
  end
@@ -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
 
170
- BugBunny.configuration.logger.error("Controller Error (#{exception.class}): #{exception.message}")
171
- BugBunny.configuration.logger.error(exception.backtrace.join("\n"))
211
+ # Fallback genérico si la excepción no fue mapeada
212
+ BugBunny.configuration.logger.error("[BugBunny::Controller] 💥 Unhandled Exception (#{exception.class}): #{exception.message}")
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