bug_bunny 3.0.5 → 3.1.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.
@@ -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
170
  BugBunny.configuration.logger.error("Controller Error (#{exception.class}): #{exception.message}")
143
171
  BugBunny.configuration.logger.error(exception.backtrace.join("\n"))
144
172
 
145
- { status: 500, body: { error: exception.message, type: exception.class.name } }
146
- end
147
-
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.
@@ -71,10 +71,11 @@ module BugBunny
71
71
  request.correlation_id ||= SecureRandom.uuid
72
72
  request.reply_to = 'amq.rabbitmq.reply-to'
73
73
  wait_timeout = request.timeout || BugBunny.configuration.rpc_timeout
74
+ cid = request.correlation_id.to_s
74
75
 
75
76
  # Creamos un futuro (IVar) que actuará como semáforo
76
77
  future = Concurrent::IVar.new
77
- @pending_requests[request.correlation_id] = future
78
+ @pending_requests[cid] = future
78
79
 
79
80
  begin
80
81
  fire(request)
@@ -90,7 +91,7 @@ module BugBunny
90
91
  parse_response(response_payload)
91
92
  ensure
92
93
  # Limpieza vital para evitar fugas de memoria en el mapa
93
- @pending_requests.delete(request.correlation_id)
94
+ @pending_requests.delete(cid)
94
95
  end
95
96
  end
96
97
 
@@ -125,10 +126,16 @@ module BugBunny
125
126
  @reply_listener_mutex.synchronize do
126
127
  return if @reply_listener_started
127
128
 
129
+ BugBunny.configuration.logger.debug("[Producer] 👂 Iniciando escucha en amq.rabbitmq.reply-to...")
130
+
128
131
  # Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
129
132
  @session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
130
- if (future = @pending_requests[props.correlation_id])
133
+ BugBunny.configuration.logger.debug("[Producer] 📥 RESPUESTA RECIBIDA | ID: #{props.correlation_id}")
134
+ incoming_cid = props.correlation_id.to_s
135
+ if (future = @pending_requests[incoming_cid])
131
136
  future.set(body)
137
+ else
138
+ BugBunny.configuration.logger.warn("[Producer] ⚠️ ID #{incoming_cid} no encontrado en pendientes: #{@pending_requests.keys}")
132
139
  end
133
140
  end
134
141
  @reply_listener_started = true
@@ -1,4 +1,5 @@
1
- # lib/bug_bunny/railtie.rb
1
+ # frozen_string_literal: true
2
+
2
3
  require 'rails'
3
4
 
4
5
  module BugBunny
@@ -11,11 +12,7 @@ module BugBunny
11
12
  # @see https://guides.rubyonrails.org/engines.html#railtie
12
13
  class Railtie < ::Rails::Railtie
13
14
  # 1. Configuración de Autoload
14
- #
15
- # Agrega el directorio `app/rabbit` a los paths de carga automática.
16
- # Esto permite que Rails encuentre automáticamente los controladores definidos por el usuario
17
- # (ej: `Rabbit::Controllers::Users`) sin necesidad de `require` manuales.
18
- initializer "bug_bunny.add_autoload_paths" do |app|
15
+ initializer 'bug_bunny.add_autoload_paths' do |app|
19
16
  rabbit_path = File.join(app.root, 'app', 'rabbit')
20
17
  if Dir.exist?(rabbit_path)
21
18
  app.config.autoload_paths << rabbit_path
@@ -23,31 +20,29 @@ module BugBunny
23
20
  end
24
21
  end
25
22
 
26
- # 2. Hook de Puma (Servidor Web)
23
+ # 2. Gestión de Forks (Puma / Spring / otros)
27
24
  #
28
- # Detecta cuando Puma arranca un nuevo "worker" en modo clúster.
29
25
  # Es vital cerrar la conexión heredada del proceso padre (Master) antes de que
30
26
  # el hijo empiece a trabajar, para evitar compartir el mismo socket TCP.
31
- #
32
- # La nueva conexión se creará perezosamente (Lazy) cuando el worker la necesite.
33
27
  config.after_initialize do
34
- if defined?(Puma)
28
+ # Estrategia 1: Rails 7.1+ ForkTracker (La forma estándar moderna)
29
+ if defined?(ActiveSupport::ForkTracker)
30
+ ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect }
31
+ end
32
+
33
+ # Estrategia 2: Hook específico de Puma (Legacy)
34
+ # Solo intentamos usarlo si la API 'events' está disponible (Puma < 5).
35
+ if defined?(Puma) && Puma.respond_to?(:events)
35
36
  Puma.events.on_worker_boot do
36
- BugBunny::Rabbit.disconnect
37
+ BugBunny.disconnect
37
38
  end
38
39
  end
39
40
  end
40
41
 
41
42
  # 3. Hook de Spring (Preloader)
42
- #
43
- # Spring mantiene una instancia de la aplicación en memoria y hace `fork` para
44
- # ejecutar comandos (rails c, rspec, etc) rápidamente.
45
- #
46
- # Al igual que con Puma, debemos desconectar la conexión al RabbitMQ justo después
47
- # del fork para asegurar que el nuevo proceso tenga su propio socket limpio.
48
43
  if defined?(Spring)
49
44
  Spring.after_fork do
50
- BugBunny::Rabbit.disconnect
45
+ BugBunny.disconnect
51
46
  end
52
47
  end
53
48
  end