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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +228 -4
- data/Rakefile +10 -6
- data/bin_worker.rb +16 -10
- data/lib/bug_bunny/{config.rb → configuration.rb} +37 -28
- data/lib/bug_bunny/consumer.rb +53 -25
- data/lib/bug_bunny/controller.rb +135 -91
- data/lib/bug_bunny/exception.rb +4 -0
- data/lib/bug_bunny/producer.rb +10 -3
- data/lib/bug_bunny/railtie.rb +14 -19
- data/lib/bug_bunny/resource.rb +69 -150
- data/lib/bug_bunny/session.rb +65 -44
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +76 -85
- data/test/integration/fire_and_forget_test.rb +76 -0
- data/test/integration/rpc_flow_test.rb +78 -0
- data/test/test_helper.rb +24 -0
- data/test/unit/configuration_test.rb +40 -0
- data/test/unit/consumer_test.rb +44 -0
- data/test/unit/controller_headers_test.rb +38 -0
- data/test/unit/hybrid_resource_test.rb +60 -0
- data/test/unit/middleware_test.rb +61 -0
- data/test/unit/resource_test.rb +49 -0
- metadata +40 -4
- data/lib/bug_bunny/rabbit.rb +0 -82
data/lib/bug_bunny/controller.rb
CHANGED
|
@@ -1,115 +1,133 @@
|
|
|
1
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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
|
|
16
|
+
# @return [Hash] Metadatos del mensaje entrante.
|
|
16
17
|
attribute :headers
|
|
17
18
|
|
|
18
|
-
# @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados
|
|
19
|
+
# @return [ActiveSupport::HashWithIndifferentAccess] Parámetros unificados.
|
|
19
20
|
attribute :params
|
|
20
21
|
|
|
21
|
-
# @return [String] Cuerpo crudo
|
|
22
|
+
# @return [String] Cuerpo crudo.
|
|
22
23
|
attribute :raw_string
|
|
23
24
|
|
|
24
|
-
# @return [Hash
|
|
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 (
|
|
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
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
# ---
|
|
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
|
-
#
|
|
102
|
-
|
|
110
|
+
# Definir el núcleo de ejecución
|
|
111
|
+
core_execution = lambda do
|
|
112
|
+
return unless run_before_actions(action_name)
|
|
103
113
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
#
|
|
112
|
-
|
|
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
|
-
#
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 = {
|
|
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
|
data/lib/bug_bunny/exception.rb
CHANGED
|
@@ -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.
|
data/lib/bug_bunny/producer.rb
CHANGED
|
@@ -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[
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/bug_bunny/railtie.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
45
|
+
BugBunny.disconnect
|
|
51
46
|
end
|
|
52
47
|
end
|
|
53
48
|
end
|