bug_bunny 3.0.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42bf10dd1d3e749561b43b298e6dda00f9b6a76a40dce49103d1d094faa39948
4
- data.tar.gz: 3088ecec60567a95972ed7abc7dff8f6dd9f5019ad6e0a19bb15dcae61e68bb3
3
+ metadata.gz: 6d012f3384230432e8f94c365d2a19ea05179f362f0433482c8d9967c3838102
4
+ data.tar.gz: ae306fdebe3c63b08a4980337294e46830a0277c9deea509a485d9978b2a9760
5
5
  SHA512:
6
- metadata.gz: ccf976738d355512f3effec2d84a94087c62b41b329516df4cb15ca99034385da216592a7b52acec6aa224708b7c5c272f17dde349a8d5fe88f3903e33ff1122
7
- data.tar.gz: 60252a5667d1178b4ac9de5118dd21cfd5538c12c9f69360988f493ffea50c81e4efec2cc08f50570fc9e76c9a57d9bb08c1ef6d75aef4a6b25f995fc765b512
6
+ metadata.gz: fdee3c4b42d2e852649985f962fe81ca0cf3b152357d484e3e37310bbacd07889c82408c92b3b99936c0f81d2cf60e22ca57bae24f54e30efb0a36040649382b
7
+ data.tar.gz: 4c47cde21b912b1cad5cda132af21cb7ea5fcb0a7cefd8827f572695fbe50d6945b6083738e81fa8cc3f40b03344314f28655586e7c298925b2bf9482034acaa
data/CHANGELOG.md CHANGED
@@ -1,4 +1,10 @@
1
1
  # Changelog
2
+ ## [3.0.3] - 2026-02-13
3
+
4
+ ### 🐛 Bug Fixes
5
+ * **Nested Query Serialization:** Fixed an issue where passing nested hashes to `Resource.where` (e.g., `where(q: { service: 'rabbit' })`) produced invalid URL strings (Ruby's `to_s` format) instead of standard HTTP query parameters.
6
+ * **Resource:** Now uses `Rack::Utils.build_nested_query` to generate correct URLs (e.g., `?q[service]=rabbit`).
7
+ * **Consumer:** Now uses `Rack::Utils.parse_nested_query` to correctly reconstruct nested hashes from the query string.
2
8
 
3
9
  ## [3.0.2] - 2026-02-12
4
10
 
data/bin_suite.rb CHANGED
@@ -24,7 +24,7 @@ begin
24
24
  response = raw_client.request('test_user/ping', exchange: 'test_exchange', exchange_type: 'topic', routing_key: 'test_user.ping')
25
25
  assert(response['body']['message'] == 'Pong!', "Respuesta RPC recibida correctamente")
26
26
  rescue => e
27
- assert(false, "Error RPC: #{e.message}")
27
+ assert(false, "Error RPC: #{e.class} - #{e.message}")
28
28
  end
29
29
 
30
30
  # ---------------------------------------------------------
@@ -36,17 +36,26 @@ puts "\n[2] Probando BugBunny::Resource (Estilo Rails)..."
36
36
  puts " -> Buscando usuario ID 123..."
37
37
  user = TestUser.find(123)
38
38
 
39
- assert(user.is_a?(TestUser), "El objeto retornado es un TestUser")
40
- assert(user.name == "Gabriel", "El nombre cargó correctamente")
41
- assert(user.persisted?, "El objeto figura como persistido")
39
+ if user
40
+ assert(user.is_a?(TestUser), "El objeto retornado es un TestUser")
41
+ assert(user.name == "Gabriel", "El nombre cargó correctamente")
42
+ assert(user.persisted?, "El objeto figura como persistido")
43
+ else
44
+ assert(false, "No se encontró el usuario (Check worker logs)")
45
+ end
46
+
42
47
  # ---------------------------------------------------------
43
48
  # TEST 3: Resource Create (ORM)
44
49
  # ---------------------------------------------------------
45
50
  puts "\n[3] Probando Resource Creation..."
46
51
  puts " -> Creando usuario nuevo..."
47
52
  new_user = TestUser.create(name: "Nuevo User", email: "new@test.com")
48
- assert(new_user.persisted?, "El usuario se guardó y recibió ID")
49
- assert(new_user.id.present?, "Tiene ID asignado por el worker (#{new_user.id})")
53
+ if new_user.persisted?
54
+ assert(new_user.persisted?, "El usuario se guardó y recibió ID")
55
+ assert(new_user.id.present?, "Tiene ID asignado por el worker (#{new_user.id})")
56
+ else
57
+ assert(false, "Fallo al crear usuario: #{new_user.errors.full_messages}")
58
+ end
50
59
 
51
60
  # ---------------------------------------------------------
52
61
  # TEST 4: Validaciones Locales
@@ -56,22 +65,42 @@ invalid_user = TestUser.new(email: "sin_nombre@test.com")
56
65
  assert(invalid_user.valid? == false, "Usuario sin nombre es inválido")
57
66
  assert(invalid_user.errors[:name].any?, "Tiene error en el campo :name")
58
67
 
59
- puts "\n🏁 SUITE FINALIZADA"
60
-
61
68
  # ---------------------------------------------------------
62
69
  # TEST 5: Probando Configuración Dinámica (.with)...
63
70
  # ---------------------------------------------------------
64
71
  puts "\n[5] Probando Configuración Dinámica (.with)..."
65
72
 
66
73
  # Probamos cambiar el routing key prefix temporalmente
67
- # El worker escucha 'test_user.*', así que si cambiamos a 'bad_prefix', debería fallar o no encontrar nada
68
74
  begin
69
- # Forzamos una routing key que no existe para ver si respeta el cambio
75
+ # Forzamos una routing key que no existe
76
+ puts " -> Intentando ruta incorrecta (esperando timeout)..."
70
77
  TestUser.with(routing_key: 'ruta.incorrecta').find(123)
71
- rescue BugBunny::RequestTimeout
72
- puts " ✅ PASS: El override funcionó (timeout esperado en ruta incorrecta)"
78
+ assert(false, "Debería haber fallado por timeout")
79
+ rescue BugBunny::RequestTimeout, BugBunny::ClientError
80
+ # Nota: Dependiendo de tu config, puede dar Timeout o 501 si llega a un worker default
81
+ puts " ✅ PASS: El override funcionó (timeout o error esperado en ruta incorrecta)"
73
82
  end
74
83
 
75
84
  # Probamos que vuelve a la normalidad
76
85
  user = TestUser.find(123)
77
86
  assert(user.present?, " ✅ PASS: La configuración volvió a la normalidad")
87
+
88
+ # ---------------------------------------------------------
89
+ # TEST 6: Filtrado Complejo (Query String Nested - FIX Rack)
90
+ # ---------------------------------------------------------
91
+ puts "\n[6] Probando Resource.where con filtros anidados (Fix Rack)..."
92
+
93
+ begin
94
+ # Esto fallaba antes (generaba string feo en la URL: {:active=>true})
95
+ # Al usar Rack, esto genera: ?q[active]=true&q[roles][]=admin
96
+ # No necesitamos que el worker responda algo real, solo que el request SALGA sin explotar URI.
97
+ TestUser.where(q: { active: true, roles: ['admin'] })
98
+ puts " ✅ PASS: .where generó la query anidada correctamente sin errores de URI."
99
+ rescue URI::InvalidURIError => e
100
+ assert(false, "❌ FAIL: URI Inválida (El fix de Rack no funcionó): #{e.message}")
101
+ rescue => e
102
+ # Si falla por conexión o 404 está bien, lo importante es que no falle al serializar
103
+ puts " ✅ PASS: El request se envió correctamente (aunque el worker responda: #{e.class}). Serialización OK."
104
+ end
105
+
106
+ puts "\n🏁 SUITE FINALIZADA"
@@ -4,41 +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
- # 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.
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.
14
18
  #
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.
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
+ # )
17
27
  class Consumer
18
- # @return [BugBunny::Session] La sesión de RabbitMQ wrapper.
28
+ # @return [BugBunny::Session] La sesión wrapper de RabbitMQ que gestiona el canal.
19
29
  attr_reader :session
20
30
 
21
31
  # 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}.
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.
24
36
  def self.subscribe(connection:, **args)
25
37
  new(connection).subscribe(**args)
26
38
  end
27
39
 
28
- # Inicializa el consumidor.
40
+ # Inicializa un nuevo consumidor.
41
+ #
29
42
  # @param connection [Bunny::Session] Conexión nativa de Bunny.
30
43
  def initialize(connection)
31
44
  @session = BugBunny::Session.new(connection)
32
45
  end
33
46
 
34
- # Inicia la suscripción a la cola y el procesamiento de mensajes.
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.
35
51
  #
36
52
  # @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).
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]
42
59
  def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', queue_opts: {}, block: true)
43
60
  x = session.exchange(name: exchange_name, type: exchange_type)
44
61
  q = session.queue(queue_name, queue_opts)
@@ -58,26 +75,25 @@ module BugBunny
58
75
 
59
76
  private
60
77
 
61
- # Procesa un mensaje individual.
78
+ # Procesa un mensaje individual recibido de la cola.
62
79
  #
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.
80
+ # Realiza la orquestación completa: Parsing -> Routing -> Ejecución -> Respuesta.
67
81
  #
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.
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]
71
86
  def process_message(delivery_info, properties, body)
72
87
  if properties.type.nil? || properties.type.empty?
73
- BugBunny.configuration.logger.error("[Consumer] Missing 'type'. Rejected.")
88
+ BugBunny.configuration.logger.error("[Consumer] Missing 'type' header. Message rejected.")
74
89
  session.channel.reject(delivery_info.delivery_tag, false)
75
90
  return
76
91
  end
77
92
 
93
+ # 1. Determinar Verbo HTTP (Default: GET)
78
94
  http_method = properties.headers ? (properties.headers['x-http-method'] || 'GET') : 'GET'
79
95
 
80
- # Inferencia de rutas (Router)
96
+ # 2. Router: Inferencia de Controlador y Acción
81
97
  route_info = router_dispatch(http_method, properties.type)
82
98
 
83
99
  headers = {
@@ -92,55 +108,57 @@ module BugBunny
92
108
  reply_to: properties.reply_to
93
109
  }
94
110
 
95
- # Instanciación dinámica del controlador
111
+ # 3. Instanciación Dinámica del Controlador
96
112
  # Ej: "users" -> Rabbit::Controllers::UsersController
97
113
  controller_class_name = "rabbit/controllers/#{route_info[:controller]}".camelize
98
114
  controller_class = controller_class_name.constantize
99
115
 
100
- # Ejecución del pipeline del controlador
116
+ # 4. Ejecución del Pipeline (Filtros -> Acción)
101
117
  response_payload = controller_class.call(headers: headers, body: body)
102
118
 
103
- # Respuesta RPC (Éxito)
119
+ # 5. Respuesta RPC (Si se solicita respuesta)
104
120
  if properties.reply_to
105
121
  reply(response_payload, properties.reply_to, properties.correlation_id)
106
122
  end
107
123
 
124
+ # 6. Acknowledge (Confirmación de procesado)
108
125
  session.channel.ack(delivery_info.delivery_tag)
109
126
 
110
127
  rescue NameError => e
111
- # Caso: Controlador o Acción no existen (404/501)
128
+ # Error 501/404: El controlador o la acción no existen.
112
129
  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
-
130
+ handle_fatal_error(properties, 501, "Routing Error", e.message)
120
131
  session.channel.reject(delivery_info.delivery_tag, false)
121
132
 
122
133
  rescue StandardError => e
123
- # Caso: Crash interno de la aplicación (500)
134
+ # Error 500: Crash interno de la aplicación.
124
135
  BugBunny.configuration.logger.error("[Consumer] Execution Error: #{e.message}")
125
-
126
- # FIX CRÍTICO: Responder con 500 para evitar Timeout
127
- if properties.reply_to
128
- error_payload = { status: 500, body: { error: "Internal Server Error", detail: e.message } }
129
- reply(error_payload, properties.reply_to, properties.correlation_id)
130
- end
131
-
136
+ handle_fatal_error(properties, 500, "Internal Server Error", e.message)
132
137
  session.channel.reject(delivery_info.delivery_tag, false)
133
138
  end
134
139
 
135
- # Simula el Router de Rails.
136
- # Convierte Verbo + Path en Controlador + Acción + ID.
140
+ # Interpreta la URL y el verbo para decidir qué controlador ejecutar.
137
141
  #
138
- # @return [Hash] { controller, action, id, params }
142
+ # Utiliza `Rack::Utils.parse_nested_query` para soportar parámetros anidados
143
+ # como `q[service]=rabbit`.
144
+ #
145
+ # @param method [String] Verbo HTTP (GET, POST, etc).
146
+ # @param path [String] URL virtual del recurso (ej: 'users/1?active=true').
147
+ # @return [Hash] Estructura con keys {:controller, :action, :id, :params}.
139
148
  def router_dispatch(method, path)
149
+ # Usamos URI para separar path de query string
140
150
  uri = URI.parse("http://dummy/#{path}")
141
151
  segments = uri.path.split('/').reject(&:empty?)
142
- query_params = uri.query ? CGI.parse(uri.query).transform_values(&:first) : {}
143
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
144
162
  controller_name = segments[0]
145
163
  id = segments[1]
146
164
 
@@ -152,18 +170,24 @@ module BugBunny
152
170
  else id || 'index'
153
171
  end
154
172
 
155
- # Soporte para Custom Member Actions (POST users/1/promote)
173
+ # Soporte para rutas miembro custom (POST users/1/promote)
156
174
  if segments.size >= 3
157
175
  id = segments[1]
158
176
  action = segments[2]
159
177
  end
160
178
 
179
+ # Inyectamos el ID en los params si existe en la ruta
161
180
  query_params['id'] = id if id
162
181
 
163
182
  { controller: controller_name, action: action, id: id, params: query_params }
164
183
  end
165
184
 
166
- # Envía la respuesta a la cola temporal del cliente (Direct Reply-to).
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]
167
191
  def reply(payload, reply_to, correlation_id)
168
192
  session.channel.default_exchange.publish(
169
193
  payload.to_json,
@@ -173,11 +197,29 @@ module BugBunny
173
197
  )
174
198
  end
175
199
 
176
- # Tarea de fondo para asegurar que la cola sigue existiendo.
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.
177
218
  def start_health_check(q_name)
178
- Concurrent::TimerTask.new(execution_interval: 60) do
219
+ Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
179
220
  session.channel.queue_declare(q_name, passive: true)
180
221
  rescue StandardError
222
+ BugBunny.configuration.logger.warn("[Consumer] Queue check failed. Reconnecting session...")
181
223
  session.close
182
224
  end.execute
183
225
  end
@@ -2,6 +2,7 @@
2
2
  require 'active_model'
3
3
  require 'active_support/core_ext/string/inflections'
4
4
  require 'uri'
5
+ require 'rack/utils'
5
6
 
6
7
  module BugBunny
7
8
  # Clase base para modelos remotos que implementan **Active Record over AMQP (RESTful)**.
@@ -17,7 +18,7 @@ module BugBunny
17
18
  # self.exchange = 'app.topic'
18
19
  # self.resource_name = 'users'
19
20
  # # Opcional: Personalizar la clave raíz del JSON
20
- # self.param_key = 'user_data'
21
+ # self.param_key = 'user_data'
21
22
  # end
22
23
  #
23
24
  # @example Uso con contexto temporal
@@ -34,16 +35,16 @@ module BugBunny
34
35
 
35
36
  # @return [HashWithIndifferentAccess] Contenedor de los atributos remotos (JSON crudo).
36
37
  attr_reader :remote_attributes
37
-
38
+
38
39
  # @return [Boolean] Indica si el objeto ha sido guardado en el servicio remoto.
39
40
  attr_accessor :persisted
40
41
 
41
42
  # @return [String, nil] Routing Key capturada en el momento de la instanciación.
42
43
  attr_accessor :routing_key
43
-
44
+
44
45
  # @return [String, nil] Exchange capturado en el momento de la instanciación.
45
46
  attr_accessor :exchange
46
-
47
+
47
48
  # @return [String, nil] Tipo de Exchange capturado en el momento de la instanciación.
48
49
  attr_accessor :exchange_type
49
50
 
@@ -76,21 +77,21 @@ module BugBunny
76
77
 
77
78
  # @return [ConnectionPool] El pool de conexiones asignado.
78
79
  def connection_pool; resolve_config(:pool, :@connection_pool); end
79
-
80
+
80
81
  # @return [String] El exchange configurado.
81
82
  # @raise [ArgumentError] Si no se ha definido un exchange.
82
83
  def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined"); end
83
-
84
+
84
85
  # @return [String] El tipo de exchange (default: direct).
85
86
  def current_exchange_type; resolve_config(:exchange_type, :@exchange_type) || 'direct'; end
86
-
87
+
87
88
  # @return [String] El nombre del recurso (ej: 'users'). Se infiere del nombre de la clase si no existe.
88
89
  def resource_name
89
90
  resolve_config(:resource_name, :@resource_name) || name.demodulize.underscore.pluralize
90
91
  end
91
92
 
92
93
  # Define la clave raíz para envolver el payload JSON (Wrapping).
93
- #
94
+ #
94
95
  # Por defecto utiliza `model_name.element`, lo que elimina los namespaces.
95
96
  # Ej: `Manager::Service` -> `'service'`.
96
97
  #
@@ -122,7 +123,7 @@ module BugBunny
122
123
  def bug_bunny_client
123
124
  pool = connection_pool
124
125
  raise BugBunny::Error, "Connection pool missing for #{name}" unless pool
125
-
126
+
126
127
  BugBunny::Client.new(pool: pool) do |conn|
127
128
  resolve_middleware_stack.each { |block| block.call(conn) }
128
129
  end
@@ -137,7 +138,7 @@ module BugBunny
137
138
  keys = { exchange: "bb_#{object_id}_exchange", exchange_type: "bb_#{object_id}_exchange_type", pool: "bb_#{object_id}_pool", routing_key: "bb_#{object_id}_routing_key" }
138
139
  old_values = {}
139
140
  keys.each { |k, v| old_values[k] = Thread.current[v] }
140
-
141
+
141
142
  # Seteamos valores temporales
142
143
  Thread.current[keys[:exchange]] = exchange if exchange
143
144
  Thread.current[keys[:exchange_type]] = exchange_type if exchange_type
@@ -150,7 +151,7 @@ module BugBunny
150
151
  ScopeProxy.new(self, keys, old_values)
151
152
  end
152
153
  end
153
-
154
+
154
155
  # Proxy para permitir encadenamiento: User.with(...).find(1)
155
156
  class ScopeProxy < BasicObject
156
157
  def initialize(target, keys, old_values); @target = target; @keys = keys; @old_values = old_values; end
@@ -179,13 +180,14 @@ module BugBunny
179
180
  def where(filters = {})
180
181
  rk = calculate_routing_key
181
182
  path = resource_name
182
- path += "?#{URI.encode_www_form(filters)}" if filters.present?
183
+
184
+ # Usamos Rack para serializar anidamiento (q[service]=val)
185
+ path += "?#{Rack::Utils.build_nested_query(filters)}" if filters.present?
183
186
 
184
187
  response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
185
188
 
186
189
  return [] unless response['body'].is_a?(Array)
187
190
  response['body'].map do |attrs|
188
- # Al instanciar aquí, se captura el contexto si estamos dentro de un .with
189
191
  inst = new(attrs)
190
192
  inst.persisted = true
191
193
  inst.send(:clear_changes_information)
@@ -204,7 +206,7 @@ module BugBunny
204
206
  response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
205
207
 
206
208
  return nil if response.nil? || response['status'] == 404
207
-
209
+
208
210
  attributes = response['body']
209
211
  return nil unless attributes.is_a?(Hash)
210
212
 
@@ -235,12 +237,12 @@ module BugBunny
235
237
  def initialize(attributes = {})
236
238
  @remote_attributes = {}.with_indifferent_access
237
239
  @persisted = false
238
-
240
+
239
241
  # === CAPTURA DE CONTEXTO ===
240
242
  @routing_key = self.class.thread_config(:routing_key)
241
243
  @exchange = self.class.thread_config(:exchange)
242
244
  @exchange_type = self.class.thread_config(:exchange_type)
243
-
245
+
244
246
  assign_attributes(attributes)
245
247
  super()
246
248
  end
@@ -269,7 +271,7 @@ module BugBunny
269
271
  return if new_attributes.nil?
270
272
  new_attributes.each { |k, v| public_send("#{k}=", v) }
271
273
  end
272
-
274
+
273
275
  def update(attributes)
274
276
  assign_attributes(attributes)
275
277
  save
@@ -325,10 +327,10 @@ module BugBunny
325
327
  run_callbacks(:save) do
326
328
  is_new = !persisted?
327
329
  rk = calculate_routing_key(id)
328
-
330
+
329
331
  # 1. Obtenemos el payload plano (atributos modificados)
330
332
  flat_payload = changes_to_send
331
-
333
+
332
334
  # 2. Wrappeamos automáticamente en la clave del modelo
333
335
  key = self.class.param_key
334
336
  wrapped_payload = { key => flat_payload }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "3.0.2"
4
+ VERSION = "3.0.3"
5
5
  end
data/test_resource.rb CHANGED
@@ -1,22 +1,19 @@
1
1
  require_relative 'test_helper'
2
2
 
3
3
  class TestUser < BugBunny::Resource
4
- # Se decide qué pool usar en cada petición
5
- self.connection_pool = -> {
6
- nil || TEST_POOL
7
- }
8
-
9
- # El exchange cambia según el entorno
10
- self.exchange = -> {
11
- ENV['IS_STAGING'] ? 'test_exchange' : 'test_exchange'
12
- }
4
+ # Configuración del Pool
5
+ self.connection_pool = -> { nil || TEST_POOL }
13
6
 
7
+ # Configuración del Exchange
8
+ self.exchange = -> { ENV['IS_STAGING'] ? 'test_exchange' : 'test_exchange' }
14
9
  self.exchange_type = 'topic'
15
- self.routing_key_prefix = 'test_user'
16
10
 
17
- attribute :id, :integer
18
- attribute :name, :string
19
- attribute :email, :string
11
+ # ACTUALIZADO v3.0: Usamos resource_name
12
+ self.resource_name = 'test_users'
13
+
14
+ # ELIMINADO: attribute :id, :integer (Causa crash en v3)
15
+ # ELIMINADO: attribute :name, :string (Causa crash en v3)
20
16
 
17
+ # Las validaciones siguen funcionando igual
21
18
  validates :name, presence: true
22
19
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-12 00:00:00.000000000 Z
11
+ date: 2026-02-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny