bug_bunny 3.0.0 → 3.0.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.
@@ -4,24 +4,20 @@ require 'active_support/core_ext/string/inflections'
4
4
  require 'uri'
5
5
 
6
6
  module BugBunny
7
- # Clase base para modelos remotos que implementan el patrón **Active Record over AMQP**.
7
+ # Clase base para modelos remotos que implementan **Active Record over AMQP (RESTful)**.
8
8
  #
9
- # Esta clase permite interactuar con microservicios remotos mediante RabbitMQ simulando
10
- # una interfaz de Active Record. Soporta **Atributos Dinámicos** (Schema-less), lo que
11
- # facilita la integración con APIs externas que no siguen convenciones de Rails (ej: Docker API en PascalCase).
9
+ # Esta clase transforma operaciones CRUD estándar en peticiones RPC utilizando
10
+ # verbos HTTP semánticos (GET, POST, PUT, DELETE) transportados sobre headers AMQP.
12
11
  #
13
- # @example Configuración Básica (Routing Dinámico)
14
- # class Node < BugBunny::Resource
15
- # self.exchange = 'swarm.topic'
16
- # # resource_name se infiere como 'nodes' (pluralizado)
17
- # # Atributos accesibles dinámicamente: node.Hostname, node.Status
12
+ # @example
13
+ # class User < BugBunny::Resource
14
+ # self.exchange = 'app.topic'
15
+ # self.resource_name = 'users'
18
16
  # end
19
17
  #
20
- # @example Configuración Estática (Cola Dedicada)
21
- # class Manager < BugBunny::Resource
22
- # self.exchange = 'swarm.direct'
23
- # self.routing_key = 'manager_queue' # Todo viaja a esta cola
24
- # end
18
+ # User.create(name: 'Gaby') # Envía POST 'users'
19
+ # u = User.find(1) # Envía GET 'users/1'
20
+ # u.destroy # Envía DELETE 'users/1'
25
21
  class Resource
26
22
  include ActiveModel::API
27
23
  include ActiveModel::Dirty
@@ -30,83 +26,61 @@ module BugBunny
30
26
 
31
27
  define_model_callbacks :save, :create, :update, :destroy
32
28
 
33
- # @return [ActiveSupport::HashWithIndifferentAccess] Almacén de los datos crudos del recurso.
34
- # @note Se llama remote_attributes para evitar conflictos con ActiveModel::AttributeSet.
35
29
  attr_reader :remote_attributes
36
-
37
- # @return [Boolean] Estado de persistencia del objeto (true si existe en remoto).
38
30
  attr_accessor :persisted
39
31
 
40
32
  class << self
41
- # @!group Configuración
42
-
43
33
  attr_writer :connection_pool, :exchange, :exchange_type, :resource_name, :routing_key
44
34
 
45
35
  # Resuelve la configuración buscando en la jerarquía de clases.
46
- #
47
- # Prioridad de resolución:
48
- # 1. Override temporal (Thread-local via `.with`).
49
- # 2. Configuración de la clase actual.
50
- # 3. Configuración de la clase padre (Herencia).
51
- #
52
- # @param key [Symbol] Clave única para el almacenamiento thread-local.
53
- # @param instance_var [Symbol] Variable de instancia a buscar (ej: :@connection_pool).
54
- # @return [Object, nil] El valor configurado o nil.
55
36
  # @api private
56
37
  def resolve_config(key, instance_var)
57
38
  thread_key = "bb_#{object_id}_#{key}"
58
39
  return Thread.current[thread_key] if Thread.current.key?(thread_key)
59
-
60
40
  target = self
61
41
  while target <= BugBunny::Resource
62
42
  value = target.instance_variable_get(instance_var)
63
- if !value.nil?
64
- return value.respond_to?(:call) ? value.call : value
65
- end
43
+ return value.respond_to?(:call) ? value.call : value unless value.nil?
66
44
  target = target.superclass
67
45
  end
68
46
  nil
69
47
  end
70
48
 
71
- # @return [ConnectionPool] El pool de conexiones a RabbitMQ.
72
- def connection_pool
73
- resolve_config(:pool, :@connection_pool)
74
- end
49
+ def connection_pool; resolve_config(:pool, :@connection_pool); end
50
+ def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined"); end
51
+ def current_exchange_type; resolve_config(:exchange_type, :@exchange_type) || 'direct'; end
75
52
 
76
- # @return [String] Nombre del exchange de RabbitMQ.
77
- # @raise [ArgumentError] Si no se ha definido el exchange.
78
- def current_exchange
79
- resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined for #{name}")
53
+ def resource_name
54
+ resolve_config(:resource_name, :@resource_name) || name.demodulize.underscore.pluralize
80
55
  end
81
56
 
82
- # @return [String] Tipo de exchange ('direct', 'topic', 'fanout'). Por defecto 'direct'.
83
- def current_exchange_type
84
- resolve_config(:exchange_type, :@exchange_type) || 'direct'
57
+ # Define middlewares para el cliente de este recurso.
58
+ def client_middleware(&block)
59
+ @client_middleware_stack ||= []
60
+ @client_middleware_stack << block
85
61
  end
86
62
 
87
- # Nombre lógico del recurso. Se usa para construir la URL en el header `type`.
88
- # Si no se configura explícitamente, infiere el nombre de la clase y lo pluraliza.
89
- # @return [String] Ej: 'services' (si la clase es Manager::Service).
90
- def resource_name
91
- resolve_config(:resource_name, :@resource_name) || name.demodulize.underscore.pluralize
63
+ # @api private
64
+ def resolve_middleware_stack
65
+ stack = []
66
+ target = self
67
+ while target <= BugBunny::Resource
68
+ middlewares = target.instance_variable_get(:@client_middleware_stack)
69
+ stack.unshift(*middlewares) if middlewares
70
+ target = target.superclass
71
+ end
72
+ stack
92
73
  end
93
74
 
94
- # Instancia el cliente RPC utilizando el pool configurado.
95
- # @return [BugBunny::Client]
96
75
  def bug_bunny_client
97
76
  pool = connection_pool
98
77
  raise BugBunny::Error, "Connection pool missing for #{name}" unless pool
99
- BugBunny::Client.new(pool: pool)
78
+
79
+ BugBunny::Client.new(pool: pool) do |conn|
80
+ resolve_middleware_stack.each { |block| block.call(conn) }
81
+ end
100
82
  end
101
83
 
102
- # Permite ejecutar un bloque con una configuración temporal (Thread-Safe).
103
- # Útil para cambiar de exchange, routing key o pool en tiempo de ejecución.
104
- #
105
- # @param exchange [String] Override del exchange.
106
- # @param routing_key [String] Override forzado de la routing key.
107
- # @param pool [ConnectionPool] Override del pool.
108
- # @yield Bloque de código donde aplica la configuración.
109
- # @return [Object] El resultado del bloque o un Proxy si no hay bloque.
110
84
  def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil)
111
85
  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" }
112
86
  old_values = {}
@@ -115,90 +89,42 @@ module BugBunny
115
89
  Thread.current[keys[:exchange_type]] = exchange_type if exchange_type
116
90
  Thread.current[keys[:pool]] = pool if pool
117
91
  Thread.current[keys[:routing_key]] = routing_key if routing_key
118
-
119
92
  if block_given?
120
93
  begin; yield; ensure; keys.each { |k, v| Thread.current[v] = old_values[k] }; end
121
94
  else
122
95
  ScopeProxy.new(self, keys, old_values)
123
96
  end
124
97
  end
125
-
126
- # @api private
98
+
127
99
  class ScopeProxy < BasicObject
128
- def initialize(target, keys, old_values)
129
- @target = target
130
- @keys = keys
131
- @old_values = old_values
132
- end
133
-
134
- def method_missing(method, *args, &block)
135
- @target.public_send(method, *args, &block)
136
- ensure
137
- @keys.each { |k, v| ::Thread.current[v] = @old_values[k] }
138
- end
100
+ def initialize(target, keys, old_values); @target = target; @keys = keys; @old_values = old_values; end
101
+ def method_missing(method, *args, &block); @target.public_send(method, *args, &block); ensure; @keys.each { |k, v| ::Thread.current[v] = @old_values[k] }; end
139
102
  end
140
103
 
141
- # Calcula la Routing Key para una acción específica.
142
- #
143
- # @param action [Symbol] La acción a realizar (:create, :update, :index, :show).
144
- # @param id [String, nil] El ID del recurso (opcional).
145
- # @return [String] La routing key calculada.
146
- def calculate_routing_key(action, id = nil)
104
+ # Calcula la Routing Key.
105
+ # @note En REST, por defecto es el resource_name (Topic 'users'), pero puede ser forzada.
106
+ def calculate_routing_key(id = nil)
147
107
  manual_rk = Thread.current["bb_#{object_id}_routing_key"]
148
108
  return manual_rk if manual_rk
149
-
150
109
  static_rk = resolve_config(:routing_key, :@routing_key)
151
110
  return static_rk if static_rk.present?
152
-
153
- key = "#{resource_name}.#{action}"
154
- key = "#{key}.#{id}" if id
155
- key
156
- end
157
-
158
- # @!group Acciones CRUD
159
-
160
- def index_action
161
- :index
111
+ resource_name
162
112
  end
163
113
 
164
- def show_action
165
- :show
166
- end
167
-
168
- def create_action
169
- :create
170
- end
114
+ # @!group Acciones CRUD RESTful
171
115
 
172
- def update_action
173
- :update
174
- end
175
-
176
- def destroy_action
177
- :destroy
178
- end
179
-
180
- # Busca recursos que coincidan con los filtros dados.
181
- # Envía una petición con header type: `resource/index?query_params`.
116
+ # GET resource?query
182
117
  #
183
- # @param filters [Hash] Filtros de búsqueda.
184
- # @return [Array<Resource>] Lista de objetos instanciados.
118
+ # @param filters [Hash] Parámetros de consulta (Query params).
185
119
  def where(filters = {})
186
- rk = calculate_routing_key(index_action)
187
- path = "#{resource_name}/#{index_action}"
120
+ rk = calculate_routing_key
121
+ path = resource_name
122
+ path += "?#{URI.encode_www_form(filters)}" if filters.present?
188
123
 
189
- if filters.present?
190
- query_string = URI.encode_www_form(filters)
191
- type_header = "#{path}?#{query_string}"
192
- else
193
- type_header = path
194
- end
195
-
196
- response = bug_bunny_client.request(type_header, exchange: current_exchange, exchange_type: current_exchange_type) do |req|
197
- req.routing_key = rk
198
- end
124
+ # REST: GET collection
125
+ response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
199
126
 
200
127
  return [] unless response['body'].is_a?(Array)
201
-
202
128
  response['body'].map do |attrs|
203
129
  inst = new(attrs)
204
130
  inst.persisted = true
@@ -207,27 +133,20 @@ module BugBunny
207
133
  end
208
134
  end
209
135
 
210
- # Retorna todos los registros del recurso remoto.
211
- # @return [Array<Resource>]
212
- def all
213
- where({})
214
- end
136
+ def all; where({}); end
215
137
 
216
- # Busca un recurso por su ID.
217
- # Envía una petición con header type: `resource/show/:id`.
138
+ # GET resource/id
218
139
  #
219
140
  # @param id [String, Integer] ID del recurso.
220
- # @return [Resource, nil] El recurso encontrado o nil si retorna 404.
221
141
  def find(id)
222
- rk = calculate_routing_key(show_action, id)
223
- type_header = "#{resource_name}/#{show_action}/#{id}"
142
+ rk = calculate_routing_key(id)
143
+ path = "#{resource_name}/#{id}"
224
144
 
225
- response = bug_bunny_client.request(type_header, exchange: current_exchange, exchange_type: current_exchange_type) do |req|
226
- req.routing_key = rk
227
- end
145
+ # REST: GET member
146
+ response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
228
147
 
229
148
  return nil if response.nil? || response['status'] == 404
230
-
149
+
231
150
  attributes = response['body']
232
151
  return nil unless attributes.is_a?(Hash)
233
152
 
@@ -237,9 +156,6 @@ module BugBunny
237
156
  instance
238
157
  end
239
158
 
240
- # Crea un nuevo recurso y lo persiste remotamente.
241
- # @param payload [Hash] Atributos iniciales.
242
- # @return [Resource] La instancia creada (persisted? será true si tuvo éxito).
243
159
  def create(payload)
244
160
  instance = new(payload)
245
161
  instance.save
@@ -248,141 +164,84 @@ module BugBunny
248
164
  end
249
165
 
250
166
  # @!group Instancia
251
- def current_exchange
252
- self.class.current_exchange
253
- end
254
-
255
- def current_exchange_type
256
- self.class.current_exchange_type
257
- end
258
-
259
- def calculate_routing_key(action, id=nil)
260
- self.class.calculate_routing_key(action, id)
261
- end
262
167
 
263
- def bug_bunny_client
264
- self.class.bug_bunny_client
265
- end
168
+ def current_exchange; self.class.current_exchange; end
169
+ def current_exchange_type; self.class.current_exchange_type; end
170
+ def calculate_routing_key(id=nil); self.class.calculate_routing_key(id); end
171
+ def bug_bunny_client; self.class.bug_bunny_client; end
266
172
 
267
- # Inicializa una nueva instancia del recurso.
268
- # @param attributes [Hash] Atributos iniciales (snake_case o PascalCase).
269
173
  def initialize(attributes = {})
270
174
  @remote_attributes = {}.with_indifferent_access
271
175
  @persisted = false
272
176
  assign_attributes(attributes)
273
- super() # Inicializa ActiveModel
177
+ super()
274
178
  end
275
179
 
276
- # Verifica si el objeto ha sido guardado en el servicio remoto.
277
- def persisted?
278
- !!@persisted
279
- end
180
+ def persisted?; !!@persisted; end
280
181
 
281
- # Asigna atributos masivamente. Utiliza los setters dinámicos.
282
- # @param new_attributes [Hash] Atributos a asignar.
283
182
  def assign_attributes(new_attributes)
284
183
  return if new_attributes.nil?
285
-
286
- new_attributes.each do |k, v|
287
- public_send("#{k}=", v)
288
- end
184
+ new_attributes.each { |k, v| public_send("#{k}=", v) }
289
185
  end
290
-
291
- # Actualiza los atributos y guarda el registro.
292
- # @param attributes [Hash] Nuevos valores.
293
- # @return [Boolean] Resultado de save.
186
+
294
187
  def update(attributes)
295
188
  assign_attributes(attributes)
296
189
  save
297
190
  end
298
191
 
299
- # Calcula el payload JSON a enviar.
300
- # Si hay cambios (Dirty Tracking), envía solo los cambios.
301
- # Si es nuevo, envía todo excepto los IDs internos.
302
- # @return [Hash] Payload.
303
192
  def changes_to_send
304
193
  return changes.transform_values(&:last) unless changes.empty?
305
-
306
194
  @remote_attributes.except('id', 'ID', 'Id', '_id')
307
195
  end
308
196
 
309
- # @!group Atributos Dinámicos (Magic Methods)
310
-
311
- # Intercepta llamadas a métodos desconocidos para leer/escribir en @remote_attributes.
312
- # Permite acceder a propiedades como `node.Hostname` o `node.Spec` dinámicamente.
197
+ # Magic Methods para atributos dinámicos
313
198
  def method_missing(method_name, *args, &block)
314
199
  attribute_name = method_name.to_s
315
-
316
200
  if attribute_name.end_with?('=')
317
- # Setter: node.Status = 'active'
318
201
  key = attribute_name.chop
319
202
  val = args.first
320
-
321
- # Dirty Tracking manual
322
203
  attribute_will_change!(key) unless @remote_attributes[key] == val
323
-
324
204
  @remote_attributes[key] = val
325
205
  else
326
- # Getter: node.Status
327
- if @remote_attributes.key?(attribute_name)
328
- @remote_attributes[attribute_name]
329
- else
330
- super
331
- end
206
+ @remote_attributes.key?(attribute_name) ? @remote_attributes[attribute_name] : super
332
207
  end
333
208
  end
334
209
 
335
- # @api private
336
210
  def respond_to_missing?(method_name, include_private = false)
337
211
  @remote_attributes.key?(method_name.to_s.sub(/=$/, '')) || super
338
212
  end
339
213
 
340
- # Retorna el ID del recurso buscando en variantes comunes (id, ID, Id, _id).
341
- # @return [String, Integer, nil]
342
214
  def id
343
215
  @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
344
216
  end
345
217
 
346
- # Asigna el ID manualmente.
347
- # @param value [Object] Nuevo ID.
348
218
  def id=(value)
349
219
  @remote_attributes['id'] = value
350
220
  end
351
221
 
352
- # Método requerido por ActiveModel::Validations para leer atributos.
353
- # @param attr [Symbol] Nombre del atributo.
354
- # @return [Object] Valor del atributo.
355
- # @api private
356
222
  def read_attribute_for_validation(attr)
357
223
  @remote_attributes[attr.to_s]
358
224
  end
359
225
 
360
- # @!group Persistencia
226
+ # @!group Persistencia RESTful
361
227
 
362
- # Guarda el recurso en el servicio remoto (Create o Update).
363
- #
364
- # * Create: POST a `resource/create`
365
- # * Update: POST a `resource/update/:id`
366
- #
367
- # @return [Boolean] true si fue exitoso, false si hubo error de validación o red.
228
+ # Guarda el registro (POST si es nuevo, PUT si existe).
368
229
  def save
369
230
  return false unless valid?
370
231
 
371
232
  run_callbacks(:save) do
372
233
  is_new = !persisted?
373
- action_verb = is_new ? self.class.create_action : self.class.update_action
374
-
234
+ rk = calculate_routing_key(id)
235
+
236
+ # Mapeo a verbos HTTP usando client.request(method: ...)
375
237
  if is_new
376
- type_header = "#{self.class.resource_name}/#{action_verb}"
377
- rk = calculate_routing_key(action_verb)
238
+ # REST: POST resource (Create)
239
+ path = self.class.resource_name
240
+ response = bug_bunny_client.request(path, method: :post, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk, body: changes_to_send)
378
241
  else
379
- type_header = "#{self.class.resource_name}/#{action_verb}/#{id}"
380
- rk = calculate_routing_key(action_verb, id)
381
- end
382
-
383
- response = bug_bunny_client.request(type_header, exchange: current_exchange, exchange_type: current_exchange_type) do |req|
384
- req.routing_key = rk
385
- req.body = changes_to_send
242
+ # REST: PUT resource/id (Update)
243
+ path = "#{self.class.resource_name}/#{id}"
244
+ response = bug_bunny_client.request(path, method: :put, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk, body: changes_to_send)
386
245
  end
387
246
 
388
247
  handle_save_response(response)
@@ -392,19 +251,16 @@ module BugBunny
392
251
  false
393
252
  end
394
253
 
395
- # Elimina el recurso remoto.
396
- # Envía petición a `resource/destroy/:id`.
397
- # @return [Boolean] true si fue eliminado exitosamente.
254
+ # Elimina el registro (DELETE).
398
255
  def destroy
399
256
  return false unless persisted?
400
257
 
401
258
  run_callbacks(:destroy) do
402
- type_header = "#{self.class.resource_name}/#{self.class.destroy_action}/#{id}"
403
- rk = calculate_routing_key(self.class.destroy_action, id)
259
+ # REST: DELETE resource/id
260
+ path = "#{self.class.resource_name}/#{id}"
261
+ rk = calculate_routing_key(id)
404
262
 
405
- bug_bunny_client.request(type_header, exchange: current_exchange, exchange_type: current_exchange_type) do |req|
406
- req.routing_key = rk
407
- end
263
+ bug_bunny_client.request(path, method: :delete, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
408
264
 
409
265
  self.persisted = false
410
266
  end
@@ -415,7 +271,6 @@ module BugBunny
415
271
 
416
272
  private
417
273
 
418
- # Procesa la respuesta exitosa del servidor RPC.
419
274
  def handle_save_response(response)
420
275
  if response['status'] == 422
421
276
  raise BugBunny::UnprocessableEntity.new(response['body']['errors'] || response['body'])
@@ -431,10 +286,8 @@ module BugBunny
431
286
  true
432
287
  end
433
288
 
434
- # Carga errores remotos en el objeto local ActiveModel::Errors.
435
289
  def load_remote_rabbit_errors(errors_hash)
436
290
  return if errors_hash.nil?
437
-
438
291
  if errors_hash.is_a?(String)
439
292
  errors.add(:base, errors_hash)
440
293
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "3.0.0"
4
+ VERSION = "3.0.1"
5
5
  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.0
4
+ version: 3.0.1
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-05 00:00:00.000000000 Z
11
+ date: 2026-02-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny