bug_bunny 3.1.0 → 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.
@@ -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
@@ -61,7 +61,7 @@ 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|
@@ -78,7 +78,7 @@ module BugBunny
78
78
  end
79
79
  end
80
80
  rescue StandardError => e
81
- 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...")
82
82
  sleep BugBunny.configuration.network_recovery_interval
83
83
  retry
84
84
  end
@@ -94,15 +94,11 @@ module BugBunny
94
94
  # @param body [String] El payload crudo del mensaje.
95
95
  # @return [void]
96
96
  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
97
+ # 1. Validación de Headers
98
+ path = properties.type || (properties.headers && properties.headers['path'])
103
99
 
104
100
  if path.nil? || path.empty?
105
- BugBunny.configuration.logger.error("[Consumer] Missing 'type' or 'path' header. Message rejected.")
101
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] ⛔ Rejected: Missing 'type' header.")
106
102
  session.channel.reject(delivery_info.delivery_tag, false)
107
103
  return
108
104
  end
@@ -114,6 +110,9 @@ module BugBunny
114
110
  # 3. Router: Inferencia de Controlador y Acción
115
111
  route_info = router_dispatch(http_method, path)
116
112
 
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)}")
115
+
117
116
  request_metadata = {
118
117
  type: path,
119
118
  http_method: http_method,
@@ -133,7 +132,7 @@ module BugBunny
133
132
  controller_name = route_info[:controller].camelize
134
133
 
135
134
  # Construcción: "Messaging::Handlers" + "::" + "Users"
136
- controller_class_name = "#{namespace}::#{controller_name}"
135
+ controller_class_name = "#{namespace}::#{controller_name}Controller"
137
136
 
138
137
  controller_class = controller_class_name.constantize
139
138
 
@@ -141,7 +140,7 @@ module BugBunny
141
140
  raise BugBunny::SecurityError, "Class #{controller_class} is not a valid BugBunny Controller"
142
141
  end
143
142
  rescue NameError => _e
144
- BugBunny.configuration.logger.error("[Consumer] Controller not found: #{controller_class_name}")
143
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Controller not found: #{controller_class_name} (Path: #{path})")
145
144
  handle_fatal_error(properties, 404, "Not Found", "Controller #{controller_class_name} not found")
146
145
  session.channel.reject(delivery_info.delivery_tag, false)
147
146
  return
@@ -159,7 +158,7 @@ module BugBunny
159
158
  session.channel.ack(delivery_info.delivery_tag)
160
159
 
161
160
  rescue StandardError => e
162
- BugBunny.configuration.logger.error("[Consumer] Execution Error: #{e.message}")
161
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Execution Error (#{e.class}): #{e.message}")
163
162
  handle_fatal_error(properties, 500, "Internal Server Error", e.message)
164
163
  session.channel.reject(delivery_info.delivery_tag, false)
165
164
  end
@@ -216,7 +215,7 @@ module BugBunny
216
215
  # @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
217
216
  # @return [void]
218
217
  def reply(payload, reply_to, correlation_id)
219
- BugBunny.configuration.logger.debug("[Consumer] 📤 Enviando REPLY a: #{reply_to} | ID: #{correlation_id}")
218
+ BugBunny.configuration.logger.debug("[BugBunny::Consumer] 📤 Sending RPC Reply to #{reply_to} | ID: #{correlation_id}")
220
219
  session.channel.default_exchange.publish(
221
220
  payload.to_json,
222
221
  routing_key: reply_to,
@@ -247,7 +246,7 @@ module BugBunny
247
246
  Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
248
247
  session.channel.queue_declare(q_name, passive: true)
249
248
  rescue StandardError
250
- BugBunny.configuration.logger.warn("[Consumer] Queue check failed. Reconnecting session...")
249
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Queue check failed. Reconnecting session...")
251
250
  session.close
252
251
  end.execute
253
252
  end
@@ -167,8 +167,8 @@ module BugBunny
167
167
  return rendered_response if rendered_response
168
168
  end
169
169
 
170
- BugBunny.configuration.logger.error("Controller Error (#{exception.class}): #{exception.message}")
171
- BugBunny.configuration.logger.error(exception.backtrace.join("\n"))
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
172
172
 
173
173
  {
174
174
  status: 500,
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'concurrent'
2
4
  require 'json'
3
5
  require 'securerandom'
@@ -27,39 +29,30 @@ module BugBunny
27
29
 
28
30
  # Envía un mensaje de forma asíncrona (Fire-and-Forget).
29
31
  #
30
- # Serializa el cuerpo del request, resuelve el exchange y publica el mensaje
31
- # sin esperar confirmación ni respuesta del consumidor.
32
+ # Serializa el cuerpo del request, resuelve el exchange aplicando la cascada de
33
+ # configuración y publica el mensaje sin esperar respuesta.
32
34
  #
33
- # @param request [BugBunny::Request] Objeto con la configuración del envío (body, routing_key, etc).
35
+ # @param request [BugBunny::Request] Objeto con la configuración del envío (body, exchange_options, etc).
34
36
  # @return [void]
35
37
  def fire(request)
36
- x = @session.exchange(name: request.exchange, type: request.exchange_type)
38
+ # Obtenemos el exchange pasando las opciones específicas del request para la fusión en cascada
39
+ x = @session.exchange(
40
+ name: request.exchange,
41
+ type: request.exchange_type,
42
+ opts: request.exchange_options
43
+ )
44
+
37
45
  payload = serialize_message(request.body)
38
46
  opts = request.amqp_options
39
47
 
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}'")
48
+ log_request(request, payload)
48
49
 
49
50
  x.publish(payload, opts.merge(routing_key: request.final_routing_key))
50
51
  end
51
52
 
52
53
  # Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
53
54
  #
54
- # Implementa el mecanismo "Direct Reply-to" de RabbitMQ (`amq.rabbitmq.reply-to`)
55
- # para recibir la respuesta directamente sin necesidad de crear colas temporales
56
- # por cada petición, lo cual mejora significativamente el rendimiento.
57
- #
58
- # El flujo es:
59
- # 1. Asegura que hay un consumidor escuchando en `amq.rabbitmq.reply-to`.
60
- # 2. Genera un `correlation_id` único.
61
- # 3. Crea una promesa (`Concurrent::IVar`) y la registra.
62
- # 4. Publica el mensaje y bloquea esperando que la promesa se resuelva.
55
+ # Implementa el mecanismo "Direct Reply-to" de RabbitMQ (`amq.rabbitmq.reply-to`).
63
56
  #
64
57
  # @param request [BugBunny::Request] Objeto request configurado.
65
58
  # @return [Hash] El cuerpo de la respuesta parseado desde JSON.
@@ -80,11 +73,12 @@ module BugBunny
80
73
  begin
81
74
  fire(request)
82
75
 
76
+ BugBunny.configuration.logger.debug("[BugBunny::Producer] ⏳ Waiting for RPC response | ID: #{cid} | Timeout: #{wait_timeout}s")
77
+
83
78
  # Bloqueamos el hilo aquí hasta que llegue la respuesta o expire el timeout
84
79
  response_payload = future.value(wait_timeout)
85
80
 
86
81
  if response_payload.nil?
87
- # CORRECCIÓN: Usamos request.path y request.method en lugar de request.action
88
82
  raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]"
89
83
  end
90
84
 
@@ -97,7 +91,21 @@ module BugBunny
97
91
 
98
92
  private
99
93
 
94
+ def log_request(request, payload)
95
+ verb = request.method.to_s.upcase
96
+ target = request.path
97
+ rk = request.final_routing_key
98
+ id = request.correlation_id
99
+
100
+ # INFO: Resumen de una línea (Traffic)
101
+ BugBunny.configuration.logger.info("[BugBunny::Producer] 📤 #{verb} /#{target} | RK: '#{rk}' | ID: #{id}")
102
+
103
+ # DEBUG: Detalle completo (Payload)
104
+ BugBunny.configuration.logger.debug("[BugBunny::Producer] 📦 Payload: #{payload.truncate(300)}") if payload.is_a?(String)
105
+ end
106
+
100
107
  # Serializa el mensaje para su transporte.
108
+ #
101
109
  # @param msg [Hash, String, Object] El mensaje a serializar.
102
110
  # @return [String] Cadena JSON o string crudo.
103
111
  def serialize_message(msg)
@@ -105,6 +113,9 @@ module BugBunny
105
113
  end
106
114
 
107
115
  # Intenta parsear la respuesta recibida.
116
+ #
117
+ # @param payload [String] El cuerpo de la respuesta recibida.
118
+ # @return [Hash] El JSON parseado.
108
119
  # @raise [BugBunny::InternalServerError] Si el payload no es JSON válido.
109
120
  def parse_response(payload)
110
121
  JSON.parse(payload)
@@ -115,27 +126,25 @@ module BugBunny
115
126
  # Inicia el consumidor de respuestas RPC de forma perezosa (Lazy Initialization).
116
127
  #
117
128
  # Utiliza un patrón de "Double-Checked Locking" con Mutex para asegurar que
118
- # solo se crea un listener por instancia de Producer, incluso en entornos multi-hilo.
129
+ # solo se crea un listener por instancia de Producer.
119
130
  #
120
- # Escucha en la pseudo-cola `amq.rabbitmq.reply-to`. Cuando llega un mensaje,
121
- # busca el `correlation_id` en el mapa de pendientes y completa el futuro (`IVar`),
122
- # desbloqueando así al hilo que llamó a {#rpc}.
131
+ # @return [void]
123
132
  def ensure_reply_listener!
124
133
  return if @reply_listener_started
125
134
 
126
135
  @reply_listener_mutex.synchronize do
127
136
  return if @reply_listener_started
128
137
 
129
- BugBunny.configuration.logger.debug("[Producer] 👂 Iniciando escucha en amq.rabbitmq.reply-to...")
138
+ BugBunny.configuration.logger.debug("[BugBunny::Producer] 👂 Starting Reply Listener on 'amq.rabbitmq.reply-to'")
130
139
 
131
140
  # Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
132
141
  @session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
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])
142
+ cid = props.correlation_id.to_s
143
+ BugBunny.configuration.logger.debug("[BugBunny::Producer] 📥 RPC Response matched | ID: #{cid}")
144
+ if (future = @pending_requests[cid])
136
145
  future.set(body)
137
- else
138
- BugBunny.configuration.logger.warn("[Producer] ⚠️ ID #{incoming_cid} no encontrado en pendientes: #{@pending_requests.keys}")
146
+ else
147
+ BugBunny.configuration.logger.warn("[BugBunny::Producer] ⚠️ Orphaned RPC Response received | ID: #{cid}")
139
148
  end
140
149
  end
141
150
  @reply_listener_started = true
@@ -1,10 +1,11 @@
1
- # lib/bug_bunny/request.rb
1
+ # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
4
  # Encapsula toda la información necesaria para realizar una petición o publicación.
5
5
  #
6
6
  # Actúa como el objeto "Environment" en la arquitectura de middlewares.
7
- # Contiene el cuerpo del mensaje, la configuración de enrutamiento y el **Verbo HTTP**.
7
+ # Contiene el cuerpo del mensaje, la configuración de enrutamiento, el **Verbo HTTP**
8
+ # y las opciones de infraestructura específicas para la petición.
8
9
  #
9
10
  # @attr body [Object] El cuerpo del mensaje (Hash, Array o String).
10
11
  # @attr headers [Hash] Cabeceras personalizadas (Headers AMQP).
@@ -14,6 +15,9 @@ module BugBunny
14
15
  # @attr exchange_type [String] El tipo de exchange ('direct', 'topic', 'fanout').
15
16
  # @attr routing_key [String] La routing key específica. Si es nil, se usará {#path}.
16
17
  # @attr timeout [Integer] Tiempo máximo en segundos para timeout RPC.
18
+ #
19
+ # @attr exchange_options [Hash] Opciones específicas para la declaración del Exchange en esta petición.
20
+ # @attr queue_options [Hash] Opciones específicas para la declaración de la Cola en esta petición.
17
21
  class Request
18
22
  attr_accessor :body
19
23
  attr_accessor :headers
@@ -24,6 +28,10 @@ module BugBunny
24
28
  attr_accessor :routing_key
25
29
  attr_accessor :timeout
26
30
 
31
+ # Configuración de Infraestructura Específica
32
+ attr_accessor :exchange_options
33
+ attr_accessor :queue_options
34
+
27
35
  # Metadatos AMQP Estándar
28
36
  attr_accessor :app_id, :content_type, :content_encoding, :priority,
29
37
  :timestamp, :expiration, :persistent, :reply_to,
@@ -40,6 +48,10 @@ module BugBunny
40
48
  @timestamp = Time.now.to_i
41
49
  @persistent = false
42
50
  @exchange_type = 'direct'
51
+
52
+ # Inicialización de opciones de infraestructura para evitar errores de nil durante el merge.
53
+ @exchange_options = {}
54
+ @queue_options = {}
43
55
  end
44
56
 
45
57
  # Calcula la Routing Key final que se usará en RabbitMQ.