bug_bunny 3.0.1 → 3.0.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.
@@ -4,24 +4,58 @@ require 'concurrent'
4
4
  require 'json'
5
5
  require 'uri'
6
6
  require 'cgi'
7
+ require 'rack/utils' # Necesario para parse_nested_query
7
8
 
8
9
  module BugBunny
9
- # Consumidor de mensajes y Router RPC estilo REST.
10
+ # Consumidor de mensajes AMQP que actúa como un Router RESTful.
10
11
  #
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.
12
+ # Esta clase es el corazón del procesamiento de mensajes en el lado del servidor/worker.
13
+ # Sus responsabilidades son:
14
+ # 1. Escuchar una cola específica.
15
+ # 2. Deserializar el mensaje y sus headers.
16
+ # 3. Enrutar el mensaje a un Controlador (`BugBunny::Controller`) basándose en el "path" y el verbo HTTP.
17
+ # 4. Gestionar el ciclo de respuesta RPC (Request-Response) para evitar timeouts en el cliente.
18
+ #
19
+ # @example Suscripción manual
20
+ # connection = BugBunny.create_connection
21
+ # BugBunny::Consumer.subscribe(
22
+ # connection: connection,
23
+ # queue_name: 'my_app_queue',
24
+ # exchange_name: 'my_exchange',
25
+ # routing_key: 'users.#'
26
+ # )
13
27
  class Consumer
28
+ # @return [BugBunny::Session] La sesión wrapper de RabbitMQ que gestiona el canal.
14
29
  attr_reader :session
15
30
 
31
+ # Método de conveniencia para instanciar y suscribir en un solo paso.
32
+ #
33
+ # @param connection [Bunny::Session] Una conexión TCP activa a RabbitMQ.
34
+ # @param args [Hash] Argumentos que se pasarán al método {#subscribe}.
35
+ # @return [BugBunny::Consumer] La instancia del consumidor creada.
16
36
  def self.subscribe(connection:, **args)
17
37
  new(connection).subscribe(**args)
18
38
  end
19
39
 
40
+ # Inicializa un nuevo consumidor.
41
+ #
42
+ # @param connection [Bunny::Session] Conexión nativa de Bunny.
20
43
  def initialize(connection)
21
44
  @session = BugBunny::Session.new(connection)
22
45
  end
23
46
 
24
- # Inicia la suscripción a la cola.
47
+ # Inicia la suscripción a la cola y comienza el bucle de procesamiento.
48
+ #
49
+ # Declara el exchange y la cola (si no existen), realiza el "binding" y
50
+ # se queda escuchando mensajes entrantes.
51
+ #
52
+ # @param queue_name [String] Nombre de la cola a escuchar.
53
+ # @param exchange_name [String] Nombre del exchange al cual enlazar la cola.
54
+ # @param routing_key [String] Patrón de enrutamiento (ej: 'users.*').
55
+ # @param exchange_type [String] Tipo de exchange ('direct', 'topic', 'fanout').
56
+ # @param queue_opts [Hash] Opciones adicionales para la cola (durable, auto_delete).
57
+ # @param block [Boolean] Si es `true`, bloquea el hilo actual (loop infinito).
58
+ # @return [void]
25
59
  def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', queue_opts: {}, block: true)
26
60
  x = session.exchange(name: exchange_name, type: exchange_type)
27
61
  q = session.queue(queue_name, queue_opts)
@@ -41,20 +75,25 @@ module BugBunny
41
75
 
42
76
  private
43
77
 
44
- # Procesa el mensaje entrante.
45
- # Infiere la acción basándose en Verbo + URL.
78
+ # Procesa un mensaje individual recibido de la cola.
79
+ #
80
+ # Realiza la orquestación completa: Parsing -> Routing -> Ejecución -> Respuesta.
81
+ #
82
+ # @param delivery_info [Bunny::DeliveryInfo] Metadatos de entrega (tag, redelivered, etc).
83
+ # @param properties [Bunny::MessageProperties] Headers y propiedades AMQP (reply_to, correlation_id).
84
+ # @param body [String] El payload crudo del mensaje.
85
+ # @return [void]
46
86
  def process_message(delivery_info, properties, body)
47
87
  if properties.type.nil? || properties.type.empty?
48
- BugBunny.configuration.logger.error("[Consumer] Missing 'type'. Rejected.")
88
+ BugBunny.configuration.logger.error("[Consumer] Missing 'type' header. Message rejected.")
49
89
  session.channel.reject(delivery_info.delivery_tag, false)
50
90
  return
51
91
  end
52
92
 
53
- # 1. Leemos el verbo HTTP desde el header (Default: GET)
54
- # Nota: Bunny devuelve los headers en propiedades.headers
93
+ # 1. Determinar Verbo HTTP (Default: GET)
55
94
  http_method = properties.headers ? (properties.headers['x-http-method'] || 'GET') : 'GET'
56
95
 
57
- # 2. Despachamos usando lógica Rails
96
+ # 2. Router: Inferencia de Controlador y Acción
58
97
  route_info = router_dispatch(http_method, properties.type)
59
98
 
60
99
  headers = {
@@ -69,78 +108,86 @@ module BugBunny
69
108
  reply_to: properties.reply_to
70
109
  }
71
110
 
72
- # Convention: "users" -> Rabbit::Controllers::UsersController
111
+ # 3. Instanciación Dinámica del Controlador
112
+ # Ej: "users" -> Rabbit::Controllers::UsersController
73
113
  controller_class_name = "rabbit/controllers/#{route_info[:controller]}".camelize
74
114
  controller_class = controller_class_name.constantize
75
115
 
116
+ # 4. Ejecución del Pipeline (Filtros -> Acción)
76
117
  response_payload = controller_class.call(headers: headers, body: body)
77
118
 
119
+ # 5. Respuesta RPC (Si se solicita respuesta)
78
120
  if properties.reply_to
79
121
  reply(response_payload, properties.reply_to, properties.correlation_id)
80
122
  end
81
123
 
124
+ # 6. Acknowledge (Confirmación de procesado)
82
125
  session.channel.ack(delivery_info.delivery_tag)
126
+
83
127
  rescue NameError => e
84
- BugBunny.configuration.logger.error("[Consumer] Controller/Action not found: #{e.message}")
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)
85
131
  session.channel.reject(delivery_info.delivery_tag, false)
132
+
86
133
  rescue StandardError => e
134
+ # Error 500: Crash interno de la aplicación.
87
135
  BugBunny.configuration.logger.error("[Consumer] Execution Error: #{e.message}")
136
+ handle_fatal_error(properties, 500, "Internal Server Error", e.message)
88
137
  session.channel.reject(delivery_info.delivery_tag, false)
89
- if properties.reply_to
90
- reply({ error: e.message }, properties.reply_to, properties.correlation_id)
91
- end
92
138
  end
93
139
 
94
- # Router: Simula el config/routes.rb de Rails.
140
+ # Interpreta la URL y el verbo para decidir qué controlador ejecutar.
141
+ #
142
+ # Utiliza `Rack::Utils.parse_nested_query` para soportar parámetros anidados
143
+ # como `q[service]=rabbit`.
95
144
  #
96
145
  # @param method [String] Verbo HTTP (GET, POST, etc).
97
- # @param path [String] URL Path (ej: 'users/1').
98
- # @return [Hash] {controller, action, id, params}
146
+ # @param path [String] URL virtual del recurso (ej: 'users/1?active=true').
147
+ # @return [Hash] Estructura con keys {:controller, :action, :id, :params}.
99
148
  def router_dispatch(method, path)
149
+ # Usamos URI para separar path de query string
100
150
  uri = URI.parse("http://dummy/#{path}")
101
- segments = uri.path.split('/').reject(&:empty?) # ["users", "123"]
102
- query_params = uri.query ? CGI.parse(uri.query).transform_values(&:first) : {}
103
-
104
- controller_name = segments[0] # "users"
105
- id = segments[1] # "123" o nil
106
-
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
151
+ segments = uri.path.split('/').reject(&:empty?)
152
+
153
+ # --- FIX: Uso de Rack para soportar params anidados ---
154
+ query_params = uri.query ? Rack::Utils.parse_nested_query(uri.query) : {}
155
+
156
+ # Si estamos en Rails, convertimos a HashWithIndifferentAccess para comodidad
157
+ if defined?(ActiveSupport::HashWithIndifferentAccess)
158
+ query_params = query_params.with_indifferent_access
159
+ end
160
+
161
+ # Lógica de Ruteo Convencional
162
+ controller_name = segments[0]
163
+ id = segments[1]
164
+
113
165
  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
166
+ when 'GET' then id ? 'show' : 'index'
167
+ when 'POST' then 'create'
168
+ when 'PUT', 'PATCH' then 'update'
169
+ when 'DELETE' then 'destroy'
170
+ else id || 'index'
124
171
  end
125
172
 
126
- # Soporte para Member Actions Custom (ej: POST users/1/activate)
127
- # Path: users/1/activate -> segments: [users, 1, activate]
173
+ # Soporte para rutas miembro custom (POST users/1/promote)
128
174
  if segments.size >= 3
129
175
  id = segments[1]
130
176
  action = segments[2]
131
177
  end
132
178
 
133
- # Inyectar ID en params para acceso unificado en el controller
179
+ # Inyectamos el ID en los params si existe en la ruta
134
180
  query_params['id'] = id if id
135
181
 
136
- {
137
- controller: controller_name,
138
- action: action,
139
- id: id,
140
- params: query_params
141
- }
182
+ { controller: controller_name, action: action, id: id, params: query_params }
142
183
  end
143
184
 
185
+ # Envía una respuesta al cliente RPC utilizando Direct Reply-to.
186
+ #
187
+ # @param payload [Hash] Cuerpo de la respuesta ({ status: ..., body: ... }).
188
+ # @param reply_to [String] Cola de respuesta (generalmente pseudo-cola amq.rabbitmq.reply-to).
189
+ # @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
190
+ # @return [void]
144
191
  def reply(payload, reply_to, correlation_id)
145
192
  session.channel.default_exchange.publish(
146
193
  payload.to_json,
@@ -150,10 +197,29 @@ module BugBunny
150
197
  )
151
198
  end
152
199
 
200
+ # Maneja errores fatales asegurando que el cliente reciba una respuesta.
201
+ # Evita que el cliente RPC se quede esperando hasta el timeout.
202
+ #
203
+ # @api private
204
+ def handle_fatal_error(properties, status, error_title, detail)
205
+ return unless properties.reply_to
206
+
207
+ error_payload = {
208
+ status: status,
209
+ body: { error: error_title, detail: detail }
210
+ }
211
+ reply(error_payload, properties.reply_to, properties.correlation_id)
212
+ end
213
+
214
+ # Tarea de fondo (Heartbeat lógico) para verificar la salud del canal.
215
+ # Si la cola desaparece o la conexión se cierra, fuerza una reconexión.
216
+ #
217
+ # @param q_name [String] Nombre de la cola a monitorear.
153
218
  def start_health_check(q_name)
154
- Concurrent::TimerTask.new(execution_interval: 60) do
219
+ Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
155
220
  session.channel.queue_declare(q_name, passive: true)
156
221
  rescue StandardError
222
+ BugBunny.configuration.logger.warn("[Consumer] Queue check failed. Reconnecting session...")
157
223
  session.close
158
224
  end.execute
159
225
  end
@@ -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)