bug_bunny 3.0.1 → 3.0.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.
@@ -8,20 +8,37 @@ require 'cgi'
8
8
  module BugBunny
9
9
  # Consumidor de mensajes y Router RPC estilo REST.
10
10
  #
11
- # Parsea el header `type` (URL) y el header `x-http-method` (Verbo)
12
- # para despachar al controlador y acción correctos siguiendo convenciones Rails.
11
+ # Esta clase se encarga de escuchar una cola específica, deserializar los mensajes,
12
+ # interpretar los headers REST (`x-http-method`, `type`) y despacharlos al
13
+ # controlador correspondiente.
14
+ #
15
+ # También gestiona el ciclo de vida de la respuesta RPC, asegurando que siempre
16
+ # se envíe una contestación (éxito o error) para evitar timeouts en el cliente.
13
17
  class Consumer
18
+ # @return [BugBunny::Session] La sesión de RabbitMQ wrapper.
14
19
  attr_reader :session
15
20
 
21
+ # Método de conveniencia para instanciar y suscribir en un solo paso.
22
+ # @param connection [Bunny::Session] Conexión activa.
23
+ # @param args [Hash] Argumentos para {#subscribe}.
16
24
  def self.subscribe(connection:, **args)
17
25
  new(connection).subscribe(**args)
18
26
  end
19
27
 
28
+ # Inicializa el consumidor.
29
+ # @param connection [Bunny::Session] Conexión nativa de Bunny.
20
30
  def initialize(connection)
21
31
  @session = BugBunny::Session.new(connection)
22
32
  end
23
33
 
24
- # Inicia la suscripción a la cola.
34
+ # Inicia la suscripción a la cola y el procesamiento de mensajes.
35
+ #
36
+ # @param queue_name [String] Nombre de la cola a escuchar.
37
+ # @param exchange_name [String] Exchange al que se bindeará la cola.
38
+ # @param routing_key [String] Routing key para el binding.
39
+ # @param exchange_type [String] Tipo de exchange ('direct', 'topic', etc).
40
+ # @param queue_opts [Hash] Opciones de declaración de la cola (durable, auto_delete).
41
+ # @param block [Boolean] Si es true, bloquea el hilo principal (loop).
25
42
  def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', queue_opts: {}, block: true)
26
43
  x = session.exchange(name: exchange_name, type: exchange_type)
27
44
  q = session.queue(queue_name, queue_opts)
@@ -41,8 +58,16 @@ module BugBunny
41
58
 
42
59
  private
43
60
 
44
- # Procesa el mensaje entrante.
45
- # Infiere la acción basándose en Verbo + URL.
61
+ # Procesa un mensaje individual.
62
+ #
63
+ # 1. Parsea headers y body.
64
+ # 2. Enruta al controlador/acción.
65
+ # 3. Envía la respuesta RPC si es necesario.
66
+ # 4. Maneja excepciones y envía errores formateados al cliente.
67
+ #
68
+ # @param delivery_info [Bunny::DeliveryInfo] Metadatos de entrega.
69
+ # @param properties [Bunny::MessageProperties] Headers y propiedades AMQP.
70
+ # @param body [String] Payload del mensaje.
46
71
  def process_message(delivery_info, properties, body)
47
72
  if properties.type.nil? || properties.type.empty?
48
73
  BugBunny.configuration.logger.error("[Consumer] Missing 'type'. Rejected.")
@@ -50,11 +75,9 @@ module BugBunny
50
75
  return
51
76
  end
52
77
 
53
- # 1. Leemos el verbo HTTP desde el header (Default: GET)
54
- # Nota: Bunny devuelve los headers en propiedades.headers
55
78
  http_method = properties.headers ? (properties.headers['x-http-method'] || 'GET') : 'GET'
56
79
 
57
- # 2. Despachamos usando lógica Rails
80
+ # Inferencia de rutas (Router)
58
81
  route_info = router_dispatch(http_method, properties.type)
59
82
 
60
83
  headers = {
@@ -69,78 +92,78 @@ module BugBunny
69
92
  reply_to: properties.reply_to
70
93
  }
71
94
 
72
- # Convention: "users" -> Rabbit::Controllers::UsersController
95
+ # Instanciación dinámica del controlador
96
+ # Ej: "users" -> Rabbit::Controllers::UsersController
73
97
  controller_class_name = "rabbit/controllers/#{route_info[:controller]}".camelize
74
98
  controller_class = controller_class_name.constantize
75
99
 
100
+ # Ejecución del pipeline del controlador
76
101
  response_payload = controller_class.call(headers: headers, body: body)
77
102
 
103
+ # Respuesta RPC (Éxito)
78
104
  if properties.reply_to
79
105
  reply(response_payload, properties.reply_to, properties.correlation_id)
80
106
  end
81
107
 
82
108
  session.channel.ack(delivery_info.delivery_tag)
109
+
83
110
  rescue NameError => e
84
- BugBunny.configuration.logger.error("[Consumer] Controller/Action not found: #{e.message}")
111
+ # Caso: Controlador o Acción no existen (404/501)
112
+ BugBunny.configuration.logger.error("[Consumer] Routing Error: #{e.message}")
113
+
114
+ # FIX CRÍTICO: Responder con error para evitar Timeout en el cliente
115
+ if properties.reply_to
116
+ error_payload = { status: 501, body: { error: "Routing Error", detail: e.message } }
117
+ reply(error_payload, properties.reply_to, properties.correlation_id)
118
+ end
119
+
85
120
  session.channel.reject(delivery_info.delivery_tag, false)
121
+
86
122
  rescue StandardError => e
123
+ # Caso: Crash interno de la aplicación (500)
87
124
  BugBunny.configuration.logger.error("[Consumer] Execution Error: #{e.message}")
88
- session.channel.reject(delivery_info.delivery_tag, false)
125
+
126
+ # FIX CRÍTICO: Responder con 500 para evitar Timeout
89
127
  if properties.reply_to
90
- reply({ error: e.message }, properties.reply_to, properties.correlation_id)
128
+ error_payload = { status: 500, body: { error: "Internal Server Error", detail: e.message } }
129
+ reply(error_payload, properties.reply_to, properties.correlation_id)
91
130
  end
131
+
132
+ session.channel.reject(delivery_info.delivery_tag, false)
92
133
  end
93
134
 
94
- # Router: Simula el config/routes.rb de Rails.
135
+ # Simula el Router de Rails.
136
+ # Convierte Verbo + Path en Controlador + Acción + ID.
95
137
  #
96
- # @param method [String] Verbo HTTP (GET, POST, etc).
97
- # @param path [String] URL Path (ej: 'users/1').
98
- # @return [Hash] {controller, action, id, params}
138
+ # @return [Hash] { controller, action, id, params }
99
139
  def router_dispatch(method, path)
100
140
  uri = URI.parse("http://dummy/#{path}")
101
- segments = uri.path.split('/').reject(&:empty?) # ["users", "123"]
141
+ segments = uri.path.split('/').reject(&:empty?)
102
142
  query_params = uri.query ? CGI.parse(uri.query).transform_values(&:first) : {}
103
143
 
104
- controller_name = segments[0] # "users"
105
- id = segments[1] # "123" o nil
144
+ controller_name = segments[0]
145
+ id = segments[1]
106
146
 
107
- # Lógica de Inferencia Rails Standard
108
- # GET users -> index
109
- # GET users/1 -> show
110
- # POST users -> create
111
- # PUT users/1 -> update
112
- # DELETE users/1 -> destroy
113
147
  action = case method.to_s.upcase
114
- when 'GET'
115
- id ? 'show' : 'index'
116
- when 'POST'
117
- 'create'
118
- when 'PUT', 'PATCH'
119
- 'update'
120
- when 'DELETE'
121
- 'destroy'
122
- else
123
- id || 'index' # Fallback para verbos custom
148
+ when 'GET' then id ? 'show' : 'index'
149
+ when 'POST' then 'create'
150
+ when 'PUT', 'PATCH' then 'update'
151
+ when 'DELETE' then 'destroy'
152
+ else id || 'index'
124
153
  end
125
154
 
126
- # Soporte para Member Actions Custom (ej: POST users/1/activate)
127
- # Path: users/1/activate -> segments: [users, 1, activate]
155
+ # Soporte para Custom Member Actions (POST users/1/promote)
128
156
  if segments.size >= 3
129
157
  id = segments[1]
130
158
  action = segments[2]
131
159
  end
132
160
 
133
- # Inyectar ID en params para acceso unificado en el controller
134
161
  query_params['id'] = id if id
135
162
 
136
- {
137
- controller: controller_name,
138
- action: action,
139
- id: id,
140
- params: query_params
141
- }
163
+ { controller: controller_name, action: action, id: id, params: query_params }
142
164
  end
143
165
 
166
+ # Envía la respuesta a la cola temporal del cliente (Direct Reply-to).
144
167
  def reply(payload, reply_to, correlation_id)
145
168
  session.channel.default_exchange.publish(
146
169
  payload.to_json,
@@ -150,6 +173,7 @@ module BugBunny
150
173
  )
151
174
  end
152
175
 
176
+ # Tarea de fondo para asegurar que la cola sigue existiendo.
153
177
  def start_health_check(q_name)
154
178
  Concurrent::TimerTask.new(execution_interval: 60) do
155
179
  session.channel.queue_declare(q_name, passive: true)
@@ -6,109 +6,183 @@ module BugBunny
6
6
  # Clase base para Controladores de Mensajes.
7
7
  #
8
8
  # Provee una abstracción similar a ActionController para manejar peticiones RPC.
9
- # Unifica el acceso a parámetros (`params`) independientemente de si vinieron
10
- # en el cuerpo del mensaje, en la URL (query string) o en la ruta (ID).
9
+ # Incluye soporte para `before_action`, manejo de excepciones declarativo (`rescue_from`)
10
+ # y normalización de parámetros.
11
11
  class Controller
12
12
  include ActiveModel::Model
13
13
  include ActiveModel::Attributes
14
14
 
15
- # @return [Hash] Metadatos completos (headers, query_params, route info).
15
+ # @return [Hash] Metadatos del mensaje (headers AMQP, routing info).
16
16
  attribute :headers
17
17
 
18
- # @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados.
18
+ # @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados (Body + Query + Route).
19
19
  attribute :params
20
20
 
21
- # @return [String] Cuerpo crudo si no es JSON.
21
+ # @return [String] Cuerpo crudo si el payload no es JSON.
22
22
  attribute :raw_string
23
23
 
24
- # @return [Hash] Respuesta renderizada.
24
+ # @return [Hash, nil] La respuesta renderizada { status, body }.
25
25
  attr_reader :rendered_response
26
26
 
27
+ # --- INFRAESTRUCTURA DE FILTROS (Before Actions) ---
28
+
27
29
  # @api private
28
30
  def self.before_actions
29
- @before_actions ||= Hash.new { |hash, key| hash[key] = [] }
31
+ @before_actions ||= Hash.new { |h, k| h[k] = [] }
30
32
  end
31
33
 
32
- # Registra un callback before_action.
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]
33
40
  def self.before_action(method_name, **options)
34
- actions = options.delete(:only) || []
35
- target = actions.empty? ? :_all_actions : actions
36
- Array(target).each do |action|
37
- key = action == :_all_actions ? :_all_actions : action.to_sym
38
- before_actions[key] << method_name
41
+ only = Array(options[:only]).map(&:to_sym)
42
+ target_actions = only.empty? ? [:_all_actions] : only
43
+
44
+ target_actions.each do |action|
45
+ before_actions[action] << method_name
39
46
  end
40
47
  end
41
48
 
42
- # Pipeline de ejecución principal.
43
- # @param headers [Hash] Metadatos parseados por el Consumer.
44
- # @param body [String, Hash] Payload.
49
+ # --- INFRAESTRUCTURA DE MANEJO DE ERRORES (Rescue From) ---
50
+
51
+ # @api private
52
+ def self.rescue_handlers
53
+ @rescue_handlers ||= []
54
+ end
55
+
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
+ def self.rescue_from(*klasses, with: nil, &block)
71
+ handler = with || block
72
+ raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
73
+
74
+ klasses.each do |klass|
75
+ # Insertamos al principio para que las últimas definiciones tengan prioridad (LIFO)
76
+ rescue_handlers.unshift([klass, handler])
77
+ end
78
+ end
79
+
80
+ # --- PIPELINE DE EJECUCIÓN ---
81
+
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 }.
45
88
  def self.call(headers:, body: {})
46
- controller = new(headers: headers)
47
- controller.prepare_params(body)
89
+ new(headers: headers).process(body)
90
+ end
48
91
 
49
- return controller.rendered_response unless controller.run_callbacks
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
+ def process(body)
98
+ prepare_params(body)
99
+ action_name = headers[:action].to_sym
50
100
 
51
- action_method = controller.headers[:action].to_sym
52
- if controller.respond_to?(action_method)
53
- controller.send(action_method)
101
+ # 1. Ejecutar Before Actions (si retorna false, hubo render/halt)
102
+ return rendered_response unless run_before_actions(action_name)
103
+
104
+ # 2. Ejecutar Acción
105
+ if respond_to?(action_name)
106
+ public_send(action_name)
54
107
  else
55
- raise NameError, "Action '#{action_method}' not found in #{name}"
108
+ raise NameError, "Action '#{action_name}' not found in #{self.class.name}"
56
109
  end
57
110
 
58
- controller.rendered_response || { status: 204, body: nil }
111
+ # 3. Respuesta por defecto (204 No Content) si la acción no llamó a render
112
+ rendered_response || { status: 204, body: nil }
113
+
59
114
  rescue StandardError => e
60
- rescue_from(e)
115
+ handle_exception(e)
116
+ end
117
+
118
+ private
119
+
120
+ # Busca un manejador registrado para la excepción y lo ejecuta.
121
+ # Si no hay ninguno, loguea y devuelve 500.
122
+ 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) }
125
+
126
+ if handler_entry
127
+ _, 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)
134
+ end
135
+
136
+ # Si el handler hizo render, retornamos esa respuesta
137
+ return rendered_response if rendered_response
138
+ end
139
+
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 } }
61
146
  end
62
147
 
63
- # Construye la respuesta RPC.
64
- # @param status [Symbol, Integer] Código HTTP equivalente (ej: :ok, 422).
65
- # @param json [Object] Objeto a devolver.
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
159
+ end
160
+
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.
66
165
  def render(status:, json: nil)
67
166
  code = Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || 200
68
167
  @rendered_response = { status: code, body: json }
69
168
  end
70
169
 
71
- # Unifica parámetros de múltiples fuentes en `params`.
170
+ # Normaliza y fusiona parámetros de múltiples fuentes.
72
171
  # Prioridad: Body > ID Ruta > Query Params.
73
- #
74
- # @param body [Hash, String] Payload.
75
172
  def prepare_params(body)
76
- self.params ||= {}.with_indifferent_access
77
-
78
- # 1. Query Params (de la URL ?active=true)
79
- if headers[:query_params].present?
80
- params.merge!(headers[:query_params])
81
- end
173
+ self.params = {}.with_indifferent_access
82
174
 
83
- # 2. ID explícito de ruta (/users/show/12)
175
+ params.merge!(headers[:query_params]) if headers[:query_params].present?
84
176
  params[:id] = headers[:id] if headers[:id].present?
85
177
 
86
- # 3. Payload Body (JSON)
87
178
  if body.is_a?(Hash)
88
179
  params.merge!(body)
89
- elsif body.is_a?(String) && headers[:content_type] =~ /json/
180
+ elsif body.is_a?(String) && headers[:content_type].to_s.include?('json')
90
181
  parsed = JSON.parse(body) rescue nil
91
182
  params.merge!(parsed) if parsed
92
183
  else
93
184
  self.raw_string = body
94
185
  end
95
186
  end
96
-
97
- # Ejecuta callbacks. Retorna false si hubo `render` (halt).
98
- # @api private
99
- def run_callbacks
100
- current = headers[:action].to_sym
101
- chain = self.class.before_actions[:_all_actions] + self.class.before_actions[current]
102
- chain.each do |method|
103
- send(method)
104
- return false if @rendered_response
105
- end
106
- true
107
- end
108
-
109
- def self.rescue_from(e)
110
- BugBunny.configuration.logger.error("Controller Error: #{e.message}")
111
- { status: 500, error: e.message }
112
- end
113
187
  end
114
188
  end
@@ -37,7 +37,14 @@ module BugBunny
37
37
  payload = serialize_message(request.body)
38
38
  opts = request.amqp_options
39
39
 
40
- BugBunny.configuration.logger.info("[BugBunny] Publishing to #{request.exchange}/#{request.final_routing_key}")
40
+ # LOG ESTRUCTURADO Y LEGIBLE
41
+ # Muestra claramente: Verbo, Recurso, Exchange (y su tipo) y la Routing Key usada.
42
+ verb = request.method.to_s.upcase
43
+ target = request.path
44
+ ex_info = "'#{request.exchange}' (Type: #{request.exchange_type})"
45
+ rk = request.final_routing_key
46
+
47
+ BugBunny.configuration.logger.info("[BugBunny] [#{verb}] '/#{target}' | Exchange: #{ex_info} | Routing Key: '#{rk}'")
41
48
 
42
49
  x.publish(payload, opts.merge(routing_key: request.final_routing_key))
43
50
  end
@@ -76,7 +83,8 @@ module BugBunny
76
83
  response_payload = future.value(wait_timeout)
77
84
 
78
85
  if response_payload.nil?
79
- raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.action}"
86
+ # CORRECCIÓN: Usamos request.path y request.method en lugar de request.action
87
+ raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]"
80
88
  end
81
89
 
82
90
  parse_response(response_payload)