bug_bunny 3.0.6 → 3.1.1

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.
@@ -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
@@ -57,10 +64,30 @@ module BugBunny
57
64
  # @return [Integer] Intervalo en segundos para verificar la salud de la cola.
58
65
  attr_accessor :health_check_interval
59
66
 
67
+ # @return [String] Namespace base donde se buscarán los controladores (default: 'Rabbit::Controllers').
68
+ attr_accessor :controller_namespace
69
+
70
+ # @return [Array<Symbol, Proc, String>] Etiquetas para el log estructurado.
71
+ attr_accessor :log_tags
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
+
60
87
  # Inicializa la configuración con valores por defecto seguros.
61
88
  def initialize
62
- @host = '127.0.0.1' # Valor por defecto explícito
63
- @port = 5672 # <--- AGREGADO (Default RabbitMQ Port)
89
+ @host = '127.0.0.1'
90
+ @port = 5672
64
91
  @username = 'guest'
65
92
  @password = 'guest'
66
93
  @vhost = '/'
@@ -80,9 +107,19 @@ module BugBunny
80
107
  @channel_prefetch = 1
81
108
  @rpc_timeout = 10
82
109
  @health_check_interval = 60
110
+
111
+ # Configuración por defecto para mantener compatibilidad
112
+ @controller_namespace = 'Rabbit::Controllers'
113
+
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 = {}
83
119
  end
84
120
 
85
121
  # Construye la URL de conexión AMQP basada en los atributos configurados.
122
+ # @return [String] URL formateada amqp://user:pass@host:port/vhost
86
123
  def url
87
124
  "amqp://#{username}:#{password}@#{host}:#{port}/#{vhost}"
88
125
  end
@@ -61,14 +61,24 @@ module BugBunny
61
61
  q = session.queue(queue_name, queue_opts)
62
62
  q.bind(x, routing_key: routing_key)
63
63
 
64
- BugBunny.configuration.logger.info("[Consumer] Listening on #{queue_name} (Exchange: #{exchange_name})")
64
+ BugBunny.configuration.logger.info("[BugBunny::Consumer] 🎧 Listening on '#{queue_name}' | Exchange: '#{exchange_name}' | Routing Key: '#{routing_key}'")
65
65
  start_health_check(queue_name)
66
66
 
67
67
  q.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
68
- process_message(delivery_info, properties, body)
68
+ trace_id = properties.correlation_id
69
+
70
+ logger = BugBunny.configuration.logger
71
+
72
+ if logger.respond_to?(:tagged)
73
+ logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
74
+ elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
75
+ Rails.logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
76
+ else
77
+ process_message(delivery_info, properties, body)
78
+ end
69
79
  end
70
80
  rescue StandardError => e
71
- BugBunny.configuration.logger.error("[Consumer] Connection Error: #{e.message}. Retrying...")
81
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Connection Error: #{e.message}. Retrying in #{BugBunny.configuration.network_recovery_interval}s...")
72
82
  sleep BugBunny.configuration.network_recovery_interval
73
83
  retry
74
84
  end
@@ -84,20 +94,27 @@ module BugBunny
84
94
  # @param body [String] El payload crudo del mensaje.
85
95
  # @return [void]
86
96
  def process_message(delivery_info, properties, body)
87
- if properties.type.nil? || properties.type.empty?
88
- BugBunny.configuration.logger.error("[Consumer] Missing 'type' header. Message rejected.")
97
+ # 1. Validación de Headers
98
+ path = properties.type || (properties.headers && properties.headers['path'])
99
+
100
+ if path.nil? || path.empty?
101
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] ⛔ Rejected: Missing 'type' header.")
89
102
  session.channel.reject(delivery_info.delivery_tag, false)
90
103
  return
91
104
  end
92
105
 
93
- # 1. Determinar Verbo HTTP (Default: GET)
94
- http_method = properties.headers ? (properties.headers['x-http-method'] || 'GET') : 'GET'
106
+ # 2. Recuperación Robusta del Verbo HTTP
107
+ headers_hash = properties.headers || {}
108
+ http_method = headers_hash['x-http-method'] || headers_hash['method'] || 'GET'
109
+
110
+ # 3. Router: Inferencia de Controlador y Acción
111
+ route_info = router_dispatch(http_method, path)
95
112
 
96
- # 2. Router: Inferencia de Controlador y Acción
97
- route_info = router_dispatch(http_method, properties.type)
113
+ BugBunny.configuration.logger.info("[BugBunny::Consumer] 📥 Started #{http_method} \"/#{path}\" for Routing Key: #{delivery_info.routing_key}")
114
+ BugBunny.configuration.logger.debug("[BugBunny::Consumer] 📦 Body: #{body.truncate(200)}")
98
115
 
99
- headers = {
100
- type: properties.type,
116
+ request_metadata = {
117
+ type: path,
101
118
  http_method: http_method,
102
119
  controller: route_info[:controller],
103
120
  action: route_info[:action],
@@ -106,33 +123,42 @@ module BugBunny
106
123
  content_type: properties.content_type,
107
124
  correlation_id: properties.correlation_id,
108
125
  reply_to: properties.reply_to
109
- }
126
+ }.merge(properties.headers)
127
+
128
+ # 4. Instanciación Dinámica del Controlador
129
+ # Utilizamos el namespace configurado en lugar de hardcodear "Rabbit::Controllers"
130
+ begin
131
+ namespace = BugBunny.configuration.controller_namespace
132
+ controller_name = route_info[:controller].camelize
110
133
 
111
- # 3. Instanciación Dinámica del Controlador
112
- # Ej: "users" -> Rabbit::Controllers::UsersController
113
- controller_class_name = "rabbit/controllers/#{route_info[:controller]}".camelize
114
- controller_class = controller_class_name.constantize
134
+ # Construcción: "Messaging::Handlers" + "::" + "Users"
135
+ controller_class_name = "#{namespace}::#{controller_name}Controller"
115
136
 
116
- # 4. Ejecución del Pipeline (Filtros -> Acción)
117
- response_payload = controller_class.call(headers: headers, body: body)
137
+ controller_class = controller_class_name.constantize
118
138
 
119
- # 5. Respuesta RPC (Si se solicita respuesta)
139
+ unless controller_class < BugBunny::Controller
140
+ raise BugBunny::SecurityError, "Class #{controller_class} is not a valid BugBunny Controller"
141
+ end
142
+ rescue NameError => _e
143
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Controller not found: #{controller_class_name} (Path: #{path})")
144
+ handle_fatal_error(properties, 404, "Not Found", "Controller #{controller_class_name} not found")
145
+ session.channel.reject(delivery_info.delivery_tag, false)
146
+ return
147
+ end
148
+
149
+ # 5. Ejecución del Pipeline (Middleware + Acción)
150
+ response_payload = controller_class.call(headers: request_metadata, body: body)
151
+
152
+ # 6. Respuesta RPC
120
153
  if properties.reply_to
121
154
  reply(response_payload, properties.reply_to, properties.correlation_id)
122
155
  end
123
156
 
124
- # 6. Acknowledge (Confirmación de procesado)
157
+ # 7. Acknowledge
125
158
  session.channel.ack(delivery_info.delivery_tag)
126
159
 
127
- rescue NameError => e
128
- # Error 501/404: El controlador o la acción no existen.
129
- BugBunny.configuration.logger.error("[Consumer] Routing Error: #{e.message}")
130
- handle_fatal_error(properties, 501, "Routing Error", e.message)
131
- session.channel.reject(delivery_info.delivery_tag, false)
132
-
133
160
  rescue StandardError => e
134
- # Error 500: Crash interno de la aplicación.
135
- BugBunny.configuration.logger.error("[Consumer] Execution Error: #{e.message}")
161
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Execution Error (#{e.class}): #{e.message}")
136
162
  handle_fatal_error(properties, 500, "Internal Server Error", e.message)
137
163
  session.channel.reject(delivery_info.delivery_tag, false)
138
164
  end
@@ -189,6 +215,7 @@ module BugBunny
189
215
  # @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
190
216
  # @return [void]
191
217
  def reply(payload, reply_to, correlation_id)
218
+ BugBunny.configuration.logger.debug("[BugBunny::Consumer] 📤 Sending RPC Reply to #{reply_to} | ID: #{correlation_id}")
192
219
  session.channel.default_exchange.publish(
193
220
  payload.to_json,
194
221
  routing_key: reply_to,
@@ -219,7 +246,7 @@ module BugBunny
219
246
  Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
220
247
  session.channel.queue_declare(q_name, passive: true)
221
248
  rescue StandardError
222
- BugBunny.configuration.logger.warn("[Consumer] Queue check failed. Reconnecting session...")
249
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Queue check failed. Reconnecting session...")
223
250
  session.close
224
251
  end.execute
225
252
  end
@@ -1,115 +1,133 @@
1
- # lib/bug_bunny/controller.rb
1
+ # frozen_string_literal: true
2
+
2
3
  require 'active_model'
3
4
  require 'rack'
5
+ require 'active_support/core_ext/class/attribute'
4
6
 
5
7
  module BugBunny
6
- # Clase base para Controladores de Mensajes.
8
+ # Clase base para todos los Controladores de Mensajes en BugBunny.
7
9
  #
8
- # Provee una abstracción similar a ActionController para manejar peticiones RPC.
9
- # Incluye soporte para `before_action`, manejo de excepciones declarativo (`rescue_from`)
10
- # y normalización de parámetros.
10
+ # @author Gabriel
11
+ # @since 3.0.6
11
12
  class Controller
12
13
  include ActiveModel::Model
13
14
  include ActiveModel::Attributes
14
15
 
15
- # @return [Hash] Metadatos del mensaje (headers AMQP, routing info).
16
+ # @return [Hash] Metadatos del mensaje entrante.
16
17
  attribute :headers
17
18
 
18
- # @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados (Body + Query + Route).
19
+ # @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados.
19
20
  attribute :params
20
21
 
21
- # @return [String] Cuerpo crudo si el payload no es JSON.
22
+ # @return [String] Cuerpo crudo.
22
23
  attribute :raw_string
23
24
 
24
- # @return [Hash, nil] La respuesta renderizada { status, body }.
25
+ # @return [Hash] Headers de respuesta.
26
+ attr_reader :response_headers
27
+
28
+ # @return [Hash, nil] Respuesta renderizada.
25
29
  attr_reader :rendered_response
26
30
 
27
- # --- INFRAESTRUCTURA DE FILTROS (Before Actions) ---
31
+ # --- INFRAESTRUCTURA DE FILTROS (DEFINICIÓN) ---
32
+ # Deben definirse ANTES de ser usados por la configuración de logs.
28
33
 
29
34
  # @api private
30
35
  def self.before_actions
31
36
  @before_actions ||= Hash.new { |h, k| h[k] = [] }
32
37
  end
33
38
 
34
- # Registra un callback que se ejecutará antes de las acciones.
35
- #
36
- # @param method_name [Symbol] Nombre del método a ejecutar.
37
- # @param options [Hash] Opciones de filtro (:only).
38
- # @example
39
- # before_action :set_user, only: [:show, :update]
39
+ # @api private
40
+ def self.around_actions
41
+ @around_actions ||= Hash.new { |h, k| h[k] = [] }
42
+ end
43
+
44
+ # Registra un filtro que se ejecutará **antes** de la acción.
40
45
  def self.before_action(method_name, **options)
46
+ register_callback(before_actions, method_name, options)
47
+ end
48
+
49
+ # Registra un filtro que **envuelve** la ejecución de la acción.
50
+ def self.around_action(method_name, **options)
51
+ register_callback(around_actions, method_name, options)
52
+ end
53
+
54
+ # Helper interno para registrar callbacks.
55
+ def self.register_callback(collection, method_name, options)
41
56
  only = Array(options[:only]).map(&:to_sym)
42
57
  target_actions = only.empty? ? [:_all_actions] : only
58
+ target_actions.each { |action| collection[action] << method_name }
59
+ end
43
60
 
44
- target_actions.each do |action|
45
- before_actions[action] << method_name
46
- end
61
+ # --- CONFIGURACIÓN DE LOGGING ---
62
+
63
+ # Define los tags que se antepondrán a cada línea de log.
64
+ class_attribute :log_tags
65
+ self.log_tags = []
66
+
67
+ # AHORA SÍ: Podemos llamar a around_action porque ya fue definido arriba.
68
+ around_action :apply_log_tags
69
+
70
+ # --- INICIALIZACIÓN ---
71
+
72
+ def initialize(attributes = {})
73
+ super
74
+ @response_headers = {}
47
75
  end
48
76
 
49
- # --- INFRAESTRUCTURA DE MANEJO DE ERRORES (Rescue From) ---
77
+ # --- MANEJO DE ERRORES ---
50
78
 
51
79
  # @api private
52
80
  def self.rescue_handlers
53
81
  @rescue_handlers ||= []
54
82
  end
55
83
 
56
- # Registra un manejador para una o más excepciones.
57
- # Los manejadores se evalúan en orden inverso (el último registrado tiene prioridad).
58
- #
59
- # @param klasses [Class] Clases de excepción a capturar.
60
- # @param with [Symbol] Nombre del método manejador.
61
- # @param block [Proc] Bloque manejador.
62
- #
63
- # @example Con método
64
- # rescue_from User::NotAuthorized, with: :deny_access
65
- #
66
- # @example Con bloque
67
- # rescue_from ActiveRecord::RecordNotFound do |e|
68
- # render status: :not_found, json: { error: e.message }
69
- # end
70
84
  def self.rescue_from(*klasses, with: nil, &block)
71
85
  handler = with || block
72
86
  raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
73
87
 
74
88
  klasses.each do |klass|
75
- # Insertamos al principio para que las últimas definiciones tengan prioridad (LIFO)
76
89
  rescue_handlers.unshift([klass, handler])
77
90
  end
78
91
  end
79
92
 
80
93
  # --- PIPELINE DE EJECUCIÓN ---
81
94
 
82
- # Punto de entrada principal llamado por el Consumer.
83
- # Instancia el controlador y procesa el mensaje.
84
- #
85
- # @param headers [Hash] Metadatos del mensaje.
86
- # @param body [Hash, String] Payload deserializado.
87
- # @return [Hash] La respuesta final { status, body }.
88
95
  def self.call(headers:, body: {})
89
96
  new(headers: headers).process(body)
90
97
  end
91
98
 
92
- # Ejecuta el ciclo de vida de la petición: Params -> Filtros -> Acción.
93
- # Captura cualquier error y delega al sistema `rescue_from`.
94
- #
95
- # @param body [Hash, String] Payload.
96
- # @return [Hash] Respuesta RPC.
97
99
  def process(body)
98
100
  prepare_params(body)
101
+
102
+ # Inyección de configuración global de logs si no hay específica
103
+ if self.class.log_tags.empty? && BugBunny.configuration.log_tags.any?
104
+ self.class.log_tags = BugBunny.configuration.log_tags
105
+ end
106
+
99
107
  action_name = headers[:action].to_sym
108
+ current_arounds = resolve_callbacks(self.class.around_actions, action_name)
100
109
 
101
- # 1. Ejecutar Before Actions (si retorna false, hubo render/halt)
102
- return rendered_response unless run_before_actions(action_name)
110
+ # Definir el núcleo de ejecución
111
+ core_execution = lambda do
112
+ return unless run_before_actions(action_name)
103
113
 
104
- # 2. Ejecutar Acción
105
- if respond_to?(action_name)
106
- public_send(action_name)
107
- else
108
- raise NameError, "Action '#{action_name}' not found in #{self.class.name}"
114
+ if respond_to?(action_name)
115
+ public_send(action_name)
116
+ else
117
+ raise NameError, "Action '#{action_name}' not found in #{self.class.name}"
118
+ end
119
+ end
120
+
121
+ # Construir la cadena de responsabilidad
122
+ execution_chain = current_arounds.reverse.inject(core_execution) do |next_step, method_name|
123
+ lambda { send(method_name, &next_step) }
109
124
  end
110
125
 
111
- # 3. Respuesta por defecto (204 No Content) si la acción no llamó a render
112
- rendered_response || { status: 204, body: nil }
126
+ # Ejecutar la cadena
127
+ execution_chain.call
128
+
129
+ # Respuesta final
130
+ rendered_response || { status: 204, headers: response_headers, body: nil }
113
131
 
114
132
  rescue StandardError => e
115
133
  handle_exception(e)
@@ -117,61 +135,59 @@ module BugBunny
117
135
 
118
136
  private
119
137
 
120
- # Busca un manejador registrado para la excepción y lo ejecuta.
121
- # Si no hay ninguno, loguea y devuelve 500.
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
152
+
122
153
  def handle_exception(exception)
123
- # Buscamos el primer handler compatible con la clase del error
124
- handler_entry = self.class.rescue_handlers.find { |klass, _| exception.is_a?(klass) }
154
+ handler_entry = self.class.rescue_handlers.find do |klass, _|
155
+ if klass.is_a?(String)
156
+ exception.class.name == klass
157
+ else
158
+ exception.is_a?(klass)
159
+ end
160
+ end
125
161
 
126
162
  if handler_entry
127
163
  _, handler = handler_entry
128
-
129
- # Ejecutamos el handler en el contexto de la INSTANCIA
130
- if handler.is_a?(Symbol)
131
- send(handler, exception)
132
- elsif handler.respond_to?(:call)
133
- instance_exec(exception, &handler)
164
+ if handler.is_a?(Symbol); send(handler, exception)
165
+ elsif handler.respond_to?(:call); instance_exec(exception, &handler)
134
166
  end
135
-
136
- # Si el handler hizo render, retornamos esa respuesta
137
167
  return rendered_response if rendered_response
138
168
  end
139
169
 
140
- # === FALLBACK POR DEFECTO ===
141
- # Si el error no fue rescatado por el usuario, actuamos como red de seguridad.
142
- BugBunny.configuration.logger.error("Controller Error (#{exception.class}): #{exception.message}")
143
- BugBunny.configuration.logger.error(exception.backtrace.join("\n"))
144
-
145
- { status: 500, body: { error: exception.message, type: exception.class.name } }
146
- end
170
+ 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
147
172
 
148
- # Ejecuta la cadena de filtros before_action.
149
- def run_before_actions(action_name)
150
- chain = (self.class.before_actions[:_all_actions] || []) +
151
- (self.class.before_actions[action_name] || [])
152
-
153
- chain.uniq.each do |method_name|
154
- send(method_name)
155
- # Si un filtro llamó a 'render', detenemos la cadena (halt)
156
- return false if rendered_response
157
- end
158
- true
173
+ {
174
+ status: 500,
175
+ headers: response_headers,
176
+ body: { error: exception.message, type: exception.class.name }
177
+ }
159
178
  end
160
179
 
161
- # Construye la respuesta RPC normalizada.
162
- #
163
- # @param status [Symbol, Integer] Código HTTP (ej: :ok, 200, :not_found).
164
- # @param json [Object] Objeto a serializar en el body.
165
180
  def render(status:, json: nil)
166
181
  code = Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || 200
167
- @rendered_response = { status: code, body: json }
182
+ @rendered_response = {
183
+ status: code,
184
+ headers: response_headers,
185
+ body: json
186
+ }
168
187
  end
169
188
 
170
- # Normaliza y fusiona parámetros de múltiples fuentes.
171
- # Prioridad: Body > ID Ruta > Query Params.
172
189
  def prepare_params(body)
173
190
  self.params = {}.with_indifferent_access
174
-
175
191
  params.merge!(headers[:query_params]) if headers[:query_params].present?
176
192
  params[:id] = headers[:id] if headers[:id].present?
177
193
 
@@ -184,5 +200,33 @@ module BugBunny
184
200
  self.raw_string = body
185
201
  end
186
202
  end
203
+
204
+ # --- LÓGICA DE LOGGING ENCAPSULADA ---
205
+
206
+ def apply_log_tags
207
+ tags = compute_tags
208
+ if defined?(Rails) && Rails.logger.respond_to?(:tagged) && tags.any?
209
+ Rails.logger.tagged(*tags) { yield }
210
+ else
211
+ yield
212
+ end
213
+ end
214
+
215
+ def compute_tags
216
+ self.class.log_tags.map do |tag|
217
+ case tag
218
+ when Proc
219
+ tag.call(self)
220
+ when Symbol
221
+ respond_to?(tag, true) ? send(tag) : tag
222
+ else
223
+ tag
224
+ end
225
+ end.compact
226
+ end
227
+
228
+ def uuid
229
+ headers[:correlation_id] || headers['X-Request-Id']
230
+ end
187
231
  end
188
232
  end
@@ -10,6 +10,10 @@ module BugBunny
10
10
  # Suele envolver excepciones nativas de la gema `bunny` (ej: TCP connection failure).
11
11
  class CommunicationError < Error; end
12
12
 
13
+ # Error lanzado cuando ocurren un acceso no permitido a controladores.
14
+ # Suele envolver excepciones nativas de la gema `bunny` (ej: TCP connection failure).
15
+ class SecurityError < Error; end
16
+
13
17
  # === Categoría: Errores del Cliente (4xx) ===
14
18
 
15
19
  # Clase base para errores causados por una petición incorrecta del cliente.