bug_bunny 3.0.6 → 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,71 +1,53 @@
1
- # lib/bug_bunny/resource.rb
1
+ # frozen_string_literal: true
2
+
2
3
  require 'active_model'
3
4
  require 'active_support/core_ext/string/inflections'
4
5
  require 'uri'
6
+ require 'set' # Necesario para el tracking manual
5
7
  require 'rack/utils'
6
8
 
7
9
  module BugBunny
8
10
  # Clase base para modelos remotos que implementan **Active Record over AMQP (RESTful)**.
9
11
  #
10
- # Esta clase transforma operaciones CRUD estándar en peticiones RPC utilizando
11
- # verbos HTTP semánticos (GET, POST, PUT, DELETE) transportados sobre headers AMQP.
12
- #
13
- # También gestiona la serialización automática de parámetros ("wrapping") para
14
- # compatibilidad con Strong Parameters de Rails.
15
- #
16
- # @example Definición de un recurso
17
- # class User < BugBunny::Resource
18
- # self.exchange = 'app.topic'
19
- # self.resource_name = 'users'
20
- # # Opcional: Personalizar la clave raíz del JSON
21
- # self.param_key = 'user_data'
22
- # end
12
+ # Soporta un esquema híbrido de datos y configuración de infraestructura en cascada:
13
+ # 1. **Defaults:** Definidos en la sesión.
14
+ # 2. **Global:** Definidos en BugBunny.configuration.
15
+ # 3. **Específico:** Definidos en la clase del recurso o vía `with`.
23
16
  #
24
- # @example Uso con contexto temporal
25
- # # La instancia 'user' recordará que debe usar la routing_key 'urgent'
26
- # user = User.with(routing_key: 'urgent').new(name: 'Gaby')
27
- # user.save # Enviará a la cola 'urgent' aunque estemos fuera del bloque .with
17
+ # @author Gabriel
18
+ # @since 3.1.0
28
19
  class Resource
29
20
  include ActiveModel::API
21
+ include ActiveModel::Attributes
30
22
  include ActiveModel::Dirty
31
23
  include ActiveModel::Validations
32
24
  extend ActiveModel::Callbacks
33
25
 
34
26
  define_model_callbacks :save, :create, :update, :destroy
35
27
 
36
- # @return [HashWithIndifferentAccess] Contenedor de los atributos remotos (JSON crudo).
37
28
  attr_reader :remote_attributes
38
-
39
- # @return [Boolean] Indica si el objeto ha sido guardado en el servicio remoto.
40
29
  attr_accessor :persisted
30
+ attr_accessor :routing_key, :exchange, :exchange_type
41
31
 
42
- # @return [String, nil] Routing Key capturada en el momento de la instanciación.
43
- attr_accessor :routing_key
44
-
45
- # @return [String, nil] Exchange capturado en el momento de la instanciación.
46
- attr_accessor :exchange
47
-
48
- # @return [String, nil] Tipo de Exchange capturado en el momento de la instanciación.
49
- attr_accessor :exchange_type
32
+ # @return [Hash] Opciones específicas de instancia para exchange y queue.
33
+ attr_accessor :exchange_options, :queue_options
50
34
 
51
35
  class << self
52
- # Configuración heredable
53
36
  attr_writer :connection_pool, :exchange, :exchange_type, :resource_name, :routing_key, :param_key
54
37
 
55
- # Lee la configuración del Thread actual (usado por el scope .with).
56
- # @api private
57
- def thread_config(key)
58
- Thread.current["bb_#{object_id}_#{key}"]
59
- end
38
+ # @!group Configuración de Infraestructura Específica
39
+ attr_writer :exchange_options, :queue_options
60
40
 
61
- # Resuelve la configuración buscando en: 1. Thread (Scope), 2. Clase, 3. Herencia.
62
41
  # @api private
42
+ def thread_config(key); Thread.current["bb_#{object_id}_#{key}"]; end
43
+
44
+ # Resuelve la configuración buscando en el hilo, luego en la jerarquía de clases.
45
+ # @param key [Symbol] Clave en el Thread.current.
46
+ # @param instance_var [Symbol] Nombre de la variable de instancia en la clase.
47
+ # @return [Object, nil]
63
48
  def resolve_config(key, instance_var)
64
- # 1. Prioridad: Contexto de hilo (.with)
65
49
  val = thread_config(key)
66
50
  return val if val
67
-
68
- # 2. Prioridad: Jerarquía de clases
69
51
  target = self
70
52
  while target <= BugBunny::Resource
71
53
  value = target.instance_variable_get(instance_var)
@@ -75,32 +57,32 @@ module BugBunny
75
57
  nil
76
58
  end
77
59
 
78
- # @return [ConnectionPool] El pool de conexiones asignado.
60
+ # @return [ConnectionPool, nil]
79
61
  def connection_pool; resolve_config(:pool, :@connection_pool); end
80
62
 
81
- # @return [String] El exchange configurado.
82
- # @raise [ArgumentError] Si no se ha definido un exchange.
83
- def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined"); end
63
+ # @return [String] Nombre del exchange actual.
64
+ def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined for #{name}"); end
84
65
 
85
- # @return [String] El tipo de exchange (default: direct).
66
+ # @return [String] Tipo de exchange ('direct', 'topic', 'fanout').
86
67
  def current_exchange_type; resolve_config(:exchange_type, :@exchange_type) || 'direct'; end
87
68
 
88
- # @return [String] El nombre del recurso (ej: 'users'). Se infiere del nombre de la clase si no existe.
69
+ # @return [Hash] Opciones de exchange específicas (Nivel 3 de la cascada).
70
+ def current_exchange_options; resolve_config(:exchange_options, :@exchange_options) || {}; end
71
+
72
+ # @return [Hash] Opciones de cola específicas.
73
+ def current_queue_options; resolve_config(:queue_options, :@queue_options) || {}; end
74
+
75
+ # @return [String] Nombre del recurso para la construcción de rutas.
89
76
  def resource_name
90
77
  resolve_config(:resource_name, :@resource_name) || name.demodulize.underscore.pluralize
91
78
  end
92
79
 
93
- # Define la clave raíz para envolver el payload JSON (Wrapping).
94
- #
95
- # Por defecto utiliza `model_name.element`, lo que elimina los namespaces.
96
- # Ej: `Manager::Service` -> `'service'`.
97
- #
98
- # @return [String] La clave paramétrica.
80
+ # @return [String] Clave raíz para envolver el payload en las peticiones.
99
81
  def param_key
100
82
  resolve_config(:param_key, :@param_key) || model_name.element
101
83
  end
102
84
 
103
- # Define un middleware para el cliente HTTP/AMQP de este recurso.
85
+ # @api private
104
86
  def client_middleware(&block)
105
87
  @client_middleware_stack ||= []
106
88
  @client_middleware_stack << block
@@ -118,32 +100,42 @@ module BugBunny
118
100
  stack
119
101
  end
120
102
 
121
- # Instancia un cliente configurado con el pool y middlewares del recurso.
103
+ # Instancia el cliente inyectando los middlewares configurados.
122
104
  # @return [BugBunny::Client]
123
105
  def bug_bunny_client
124
106
  pool = connection_pool
125
107
  raise BugBunny::Error, "Connection pool missing for #{name}" unless pool
126
-
127
108
  BugBunny::Client.new(pool: pool) do |conn|
128
109
  resolve_middleware_stack.each { |block| block.call(conn) }
129
110
  end
130
111
  end
131
112
 
132
- # Ejecuta un bloque (o retorna un Proxy) con una configuración temporal.
133
- # Útil para cambiar de exchange o routing_key para una operación específica.
113
+ # Permite configurar dinámicamente el contexto AMQP para una operación.
134
114
  #
135
- # @example
136
- # User.with(routing_key: 'urgent').create(params)
137
- def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil)
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" }
115
+ # @param exchange [String] Nombre del exchange.
116
+ # @param routing_key [String] Routing key manual.
117
+ # @param exchange_type [String] Tipo de exchange.
118
+ # @param pool [ConnectionPool] Pool de conexiones.
119
+ # @param exchange_options [Hash] Opciones de infraestructura.
120
+ # @param queue_options [Hash] Opciones de cola.
121
+ def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil, exchange_options: nil, queue_options: nil)
122
+ keys = {
123
+ exchange: "bb_#{object_id}_exchange",
124
+ exchange_type: "bb_#{object_id}_exchange_type",
125
+ pool: "bb_#{object_id}_pool",
126
+ routing_key: "bb_#{object_id}_routing_key",
127
+ exchange_options: "bb_#{object_id}_exchange_options",
128
+ queue_options: "bb_#{object_id}_queue_options"
129
+ }
139
130
  old_values = {}
140
131
  keys.each { |k, v| old_values[k] = Thread.current[v] }
141
132
 
142
- # Seteamos valores temporales
143
133
  Thread.current[keys[:exchange]] = exchange if exchange
144
134
  Thread.current[keys[:exchange_type]] = exchange_type if exchange_type
145
135
  Thread.current[keys[:pool]] = pool if pool
146
136
  Thread.current[keys[:routing_key]] = routing_key if routing_key
137
+ Thread.current[keys[:exchange_options]] = exchange_options if exchange_options
138
+ Thread.current[keys[:queue_options]] = queue_options if queue_options
147
139
 
148
140
  if block_given?
149
141
  begin; yield; ensure; keys.each { |k, v| Thread.current[v] = old_values[k] }; end
@@ -152,39 +144,42 @@ module BugBunny
152
144
  end
153
145
  end
154
146
 
155
- # Proxy para permitir encadenamiento: User.with(...).find(1)
147
+ # Proxy para el encadenamiento del método `.with`.
156
148
  class ScopeProxy < BasicObject
157
149
  def initialize(target, keys, old_values); @target = target; @keys = keys; @old_values = old_values; end
158
150
  def method_missing(method, *args, &block); @target.public_send(method, *args, &block); ensure; @keys.each { |k, v| ::Thread.current[v] = @old_values[k] }; end
159
151
  end
160
152
 
161
- # Calcula la Routing Key.
153
+ # Calcula la routing key final.
154
+ # @param id [String, nil] ID del recurso.
162
155
  # @return [String]
163
156
  def calculate_routing_key(id = nil)
164
- # 1. Contexto .with
165
157
  manual_rk = thread_config(:routing_key)
166
158
  return manual_rk if manual_rk
167
-
168
- # 2. Configuración estática
169
159
  static_rk = resolve_config(:routing_key, :@routing_key)
170
160
  return static_rk if static_rk.present?
171
-
172
- # 3. Default: Resource name
173
161
  resource_name
174
162
  end
175
163
 
176
- # @!group Acciones CRUD RESTful (Clase)
164
+ # @!group Acciones CRUD RESTful
177
165
 
178
- # Busca recursos que coincidan con los filtros.
179
- # Envía: GET resource?query
166
+ # Realiza una búsqueda filtrada (GET).
167
+ # @param filters [Hash]
168
+ # @return [Array<BugBunny::Resource>]
180
169
  def where(filters = {})
181
170
  rk = calculate_routing_key
182
171
  path = resource_name
183
-
184
- # Usamos Rack para serializar anidamiento (q[service]=val)
185
172
  path += "?#{Rack::Utils.build_nested_query(filters)}" if filters.present?
186
173
 
187
- response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
174
+ response = bug_bunny_client.request(
175
+ path,
176
+ method: :get,
177
+ exchange: current_exchange,
178
+ exchange_type: current_exchange_type,
179
+ routing_key: rk,
180
+ exchange_options: current_exchange_options,
181
+ queue_options: current_queue_options
182
+ )
188
183
 
189
184
  return [] unless response['body'].is_a?(Array)
190
185
  response['body'].map do |attrs|
@@ -195,28 +190,38 @@ module BugBunny
195
190
  end
196
191
  end
197
192
 
193
+ # Devuelve todos los registros.
194
+ # @return [Array<BugBunny::Resource>]
198
195
  def all; where({}); end
199
196
 
200
- # Busca un recurso por ID.
201
- # Envía: GET resource/id
197
+ # Busca un registro por ID (GET).
198
+ # @param id [String, Integer]
199
+ # @return [BugBunny::Resource, nil]
202
200
  def find(id)
203
201
  rk = calculate_routing_key(id)
204
202
  path = "#{resource_name}/#{id}"
205
203
 
206
- response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
204
+ response = bug_bunny_client.request(
205
+ path,
206
+ method: :get,
207
+ exchange: current_exchange,
208
+ exchange_type: current_exchange_type,
209
+ routing_key: rk,
210
+ exchange_options: current_exchange_options,
211
+ queue_options: current_queue_options
212
+ )
207
213
 
208
214
  return nil if response.nil? || response['status'] == 404
209
-
210
- attributes = response['body']
211
- return nil unless attributes.is_a?(Hash)
212
-
213
- instance = new(attributes)
215
+ return nil unless response['body'].is_a?(Hash)
216
+ instance = new(response['body'])
214
217
  instance.persisted = true
215
218
  instance.send(:clear_changes_information)
216
219
  instance
217
220
  end
218
221
 
219
- # Crea un nuevo recurso.
222
+ # Crea una nueva instancia y la persiste.
223
+ # @param payload [Hash]
224
+ # @return [BugBunny::Resource]
220
225
  def create(payload)
221
226
  instance = new(payload)
222
227
  instance.save
@@ -226,70 +231,94 @@ module BugBunny
226
231
 
227
232
  # @!group Instancia
228
233
 
229
- # Inicializa una nueva instancia del recurso.
230
- #
231
- # **IMPORTANTE:** Captura la configuración del contexto actual (`.with`)
232
- # y la guarda en la instancia. Esto permite que objetos creados dentro de un bloque `with`
233
- # mantengan esa configuración (routing_key, exchange) durante todo su ciclo de vida,
234
- # incluso si `save` se llama fuera del bloque.
235
- #
236
- # @param attributes [Hash] Atributos iniciales.
234
+ # Inicializa el recurso.
235
+ # @param attributes [Hash]
237
236
  def initialize(attributes = {})
238
237
  @remote_attributes = {}.with_indifferent_access
238
+ @dynamic_changes = Set.new # Rastreo manual para atributos dinámicos
239
239
  @persisted = false
240
240
 
241
- # === CAPTURA DE CONTEXTO ===
241
+ # Contexto de infraestructura
242
242
  @routing_key = self.class.thread_config(:routing_key)
243
243
  @exchange = self.class.thread_config(:exchange)
244
244
  @exchange_type = self.class.thread_config(:exchange_type)
245
+ @exchange_options = self.class.thread_config(:exchange_options) || self.class.current_exchange_options
246
+ @queue_options = self.class.thread_config(:queue_options) || self.class.current_queue_options
245
247
 
246
- assign_attributes(attributes)
247
248
  super()
249
+ assign_attributes(attributes)
248
250
  end
249
251
 
250
- # Prioridad Routing Key: 1. Instancia (Capturada), 2. Clase
251
- def calculate_routing_key(id=nil)
252
- return @routing_key if @routing_key
253
- self.class.calculate_routing_key(id)
252
+ # Limpia tanto el rastreo de ActiveModel como nuestro rastreo dinámico.
253
+ def clear_changes_information
254
+ super
255
+ @dynamic_changes.clear
254
256
  end
255
257
 
256
- # Prioridad Exchange: 1. Instancia (Capturada), 2. Clase
257
- def current_exchange
258
- @exchange || self.class.current_exchange
258
+ # Serialización combinada.
259
+ # @return [Hash]
260
+ def attributes_for_serialization
261
+ @remote_attributes.merge(attributes)
259
262
  end
260
263
 
261
- # Prioridad Exchange Type: 1. Instancia (Capturada), 2. Clase
262
- def current_exchange_type
263
- @exchange_type || self.class.current_exchange_type
264
- end
264
+ # @return [String]
265
+ def calculate_routing_key(id=nil); @routing_key || self.class.calculate_routing_key(id); end
266
+
267
+ # @return [String]
268
+ def current_exchange; @exchange || self.class.current_exchange; end
269
+
270
+ # @return [String]
271
+ def current_exchange_type; @exchange_type || self.class.current_exchange_type; end
265
272
 
273
+ # @return [BugBunny::Client]
266
274
  def bug_bunny_client; self.class.bug_bunny_client; end
267
275
 
276
+ # @return [Boolean]
268
277
  def persisted?; !!@persisted; end
269
278
 
279
+ # Asignación masiva de atributos.
280
+ # @param new_attributes [Hash]
270
281
  def assign_attributes(new_attributes)
271
282
  return if new_attributes.nil?
272
283
  new_attributes.each { |k, v| public_send("#{k}=", v) }
273
284
  end
274
285
 
286
+ # Actualiza y guarda.
287
+ # @param attributes [Hash]
288
+ # @return [Boolean]
275
289
  def update(attributes)
276
290
  assign_attributes(attributes)
277
291
  save
278
292
  end
279
293
 
280
- # Retorna solo los atributos que han cambiado.
294
+ # Retorna el hash combinado de cambios (Tipados + Dinámicos).
295
+ # @return [Hash]
281
296
  def changes_to_send
282
- return changes.transform_values(&:last) unless changes.empty?
283
- @remote_attributes.except('id', 'ID', 'Id', '_id')
297
+ # 1. Cambios de ActiveModel (Tipados)
298
+ payload = changes.transform_values(&:last)
299
+
300
+ # 2. Cambios Dinámicos (Manuales)
301
+ @dynamic_changes.each do |key|
302
+ payload[key] = @remote_attributes[key]
303
+ end
304
+
305
+ return payload unless payload.empty?
306
+
307
+ # Fallback: Si no hay cambios detectados, enviamos todo (útil para create)
308
+ attributes_for_serialization.except('id', 'ID', 'Id', '_id')
284
309
  end
285
310
 
286
- # Métodos mágicos para atributos.
311
+ # Intercepta asignaciones dinámicas y las marca como sucias.
287
312
  def method_missing(method_name, *args, &block)
288
313
  attribute_name = method_name.to_s
289
314
  if attribute_name.end_with?('=')
290
315
  key = attribute_name.chop
291
316
  val = args.first
292
- attribute_will_change!(key) unless @remote_attributes[key] == val
317
+
318
+ if @remote_attributes[key] != val
319
+ @dynamic_changes << key
320
+ end
321
+
293
322
  @remote_attributes[key] = val
294
323
  else
295
324
  @remote_attributes.key?(attribute_name) ? @remote_attributes[attribute_name] : super
@@ -300,51 +329,51 @@ module BugBunny
300
329
  @remote_attributes.key?(method_name.to_s.sub(/=$/, '')) || super
301
330
  end
302
331
 
332
+ # @return [Object] Valor del ID buscando en múltiples nomenclaturas.
303
333
  def id
304
- @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
334
+ attributes['id'] || @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
305
335
  end
306
336
 
307
337
  def id=(value)
308
- @remote_attributes['id'] = value
338
+ if self.class.attribute_names.include?('id')
339
+ super(value)
340
+ else
341
+ @remote_attributes['id'] = value
342
+ end
309
343
  end
310
344
 
311
345
  def read_attribute_for_validation(attr)
312
- @remote_attributes[attr.to_s]
346
+ attr_s = attr.to_s
347
+ self.class.attribute_names.include?(attr_s) ? attribute(attr_s) : @remote_attributes[attr_s]
313
348
  end
314
349
 
315
- # @!group Persistencia RESTful
350
+ # @!group Persistencia
316
351
 
317
- # Guarda el registro.
318
- # Envía POST si es nuevo, PUT si ya existe.
319
- #
320
- # **AUTOMÁTICO:** Envuelve los parámetros en la clave del modelo (`param_key`).
321
- # Ej: Manager::Service -> "service". Esto facilita `params.require(:service)`.
322
- #
323
- # @return [Boolean] true si se guardó correctamente.
352
+ # Guarda el recurso en el servidor remoto vía AMQP (POST o PUT).
353
+ # @return [Boolean]
324
354
  def save
325
355
  return false unless valid?
326
356
 
327
357
  run_callbacks(:save) do
328
358
  is_new = !persisted?
329
359
  rk = calculate_routing_key(id)
330
-
331
- # 1. Obtenemos el payload plano (atributos modificados)
332
360
  flat_payload = changes_to_send
333
-
334
- # 2. Wrappeamos automáticamente en la clave del modelo
335
361
  key = self.class.param_key
336
362
  wrapped_payload = { key => flat_payload }
337
363
 
338
- # Mapeo a verbos HTTP
339
- if is_new
340
- # REST: POST resource (Create)
341
- path = self.class.resource_name
342
- response = bug_bunny_client.request(path, method: :post, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk, body: wrapped_payload)
343
- else
344
- # REST: PUT resource/id (Update)
345
- path = "#{self.class.resource_name}/#{id}"
346
- response = bug_bunny_client.request(path, method: :put, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk, body: wrapped_payload)
347
- end
364
+ path = is_new ? self.class.resource_name : "#{self.class.resource_name}/#{id}"
365
+ method = is_new ? :post : :put
366
+
367
+ response = bug_bunny_client.request(
368
+ path,
369
+ method: method,
370
+ exchange: current_exchange,
371
+ exchange_type: current_exchange_type,
372
+ routing_key: rk,
373
+ exchange_options: @exchange_options,
374
+ queue_options: @queue_options,
375
+ body: wrapped_payload
376
+ )
348
377
 
349
378
  handle_save_response(response)
350
379
  end
@@ -353,16 +382,23 @@ module BugBunny
353
382
  false
354
383
  end
355
384
 
356
- # Elimina el registro.
357
- # Envía DELETE resource/id.
385
+ # Elimina el recurso del servidor remoto (DELETE).
386
+ # @return [Boolean]
358
387
  def destroy
359
388
  return false unless persisted?
360
-
361
389
  run_callbacks(:destroy) do
362
390
  path = "#{self.class.resource_name}/#{id}"
363
391
  rk = calculate_routing_key(id)
364
392
 
365
- bug_bunny_client.request(path, method: :delete, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
393
+ bug_bunny_client.request(
394
+ path,
395
+ method: :delete,
396
+ exchange: current_exchange,
397
+ exchange_type: current_exchange_type,
398
+ routing_key: rk,
399
+ exchange_options: @exchange_options,
400
+ queue_options: @queue_options
401
+ )
366
402
 
367
403
  self.persisted = false
368
404
  end
@@ -373,6 +409,7 @@ module BugBunny
373
409
 
374
410
  private
375
411
 
412
+ # Maneja la lógica de respuesta para la acción de guardado.
376
413
  def handle_save_response(response)
377
414
  if response['status'] == 422
378
415
  raise BugBunny::UnprocessableEntity.new(response['body']['errors'] || response['body'])
@@ -388,6 +425,7 @@ module BugBunny
388
425
  true
389
426
  end
390
427
 
428
+ # Carga errores remotos en el objeto local.
391
429
  def load_remote_rabbit_errors(errors_hash)
392
430
  return if errors_hash.nil?
393
431
  if errors_hash.is_a?(String)