bug_bunny 3.1.6 → 4.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fec9f487076682121e6d8586e44fe9d70f380bd809fb46014265105421a0d8f
4
- data.tar.gz: e847fc6f1943f9921f2250a46a20bd6f76b20736812fcbf6bfc437ab49a8a30c
3
+ metadata.gz: b06b89852caa37c57d4db098d7f2f51f6d6f7361898ce5fc3484ad81e31068fd
4
+ data.tar.gz: cc53173b422e52e54e3a3a8af54676f6a954279ab81847335be3b37a5251b882
5
5
  SHA512:
6
- metadata.gz: 199faaa596f52cc1c192259e8fe3e7da95152d117db9ded079051bebdff985793e63acde11cb8ceed5e0e216d6934ff35056993947609f2818d8429f524a789a
7
- data.tar.gz: dfefdd78b4597f90dc059ef07d7423dda86d2630cdab409c514941708cc539d174a763d1a6e18c918dee7058d25c0a996aac0bb52f1b440573b42480e723e284
6
+ metadata.gz: 6643abb20472bea870426d694d3481c6ced6bd1bf2d0a26e6cbc0526f60050b016de265785611cd4e7046304cc88058104845172d676475be1dc7c6b6ad79d10
7
+ data.tar.gz: 3f60029f149d765752616323fcf53ce9406ca80e3cffeeaf008bf8a54daac0f06366495081629b1f22127e6bc171080a5946f89341d5cebb5929596d70275bc1
data/CHANGELOG.md CHANGED
@@ -1,4 +1,21 @@
1
1
  # Changelog
2
+ ## [4.0.0] - 2026-03-02
3
+
4
+ ### ⚠ Breaking Changes
5
+ * **Declarative Routing (Rails-style):** El enrutamiento "mágico" y heurístico del Consumer ha sido reemplazado por un motor de enrutamiento explícito y estricto.
6
+ * Ahora es **obligatorio** definir un mapa de rutas usando el DSL `BugBunny.routes.draw` (típicamente en un inicializador como `config/initializers/bug_bunny_routes.rb`).
7
+ * Los mensajes entrantes cuyas rutas no estén explícitamente declaradas serán rechazados inmediatamente con un error `404 Not Found`.
8
+
9
+ ### 🚀 New Features & Architecture
10
+ * **Advanced Routing DSL:** Se construyó un motor de enrutamiento completo y robusto inspirado en `ActionDispatch::Routing` de Rails.
11
+ * **Smart Route Parameters:** Compilación de rutas a expresiones regulares, permitiendo la extracción nativa de parámetros dinámicos desde la URL (ej. `get 'clusters/:cluster_id/nodes/:id/metrics'`). Estos se inyectan automáticamente en el hash `params` del Controlador.
12
+ * **Resource Macros & Filtering:** Introducción del macro `resources :name` para generar endpoints CRUD estándar. Ahora soporta filtrado granular de acciones utilizando las opciones `only:` y `except:`.
13
+ * **Nested Scopes (Member/Collection):** Soporte total para bloques anidados `member do ... end` y `collection do ... end` dentro de los recursos, permitiendo definir rutas complejas infiriendo automáticamente el controlador destino y la inyección del `:id`.
14
+
15
+ ### 🛡️ Security & Observability
16
+ * **Strict Instantiation (RCE Prevention):** Al requerir que todas las rutas sean declaradas explícitamente por el desarrollador, se elimina por completo el vector de ataque que permitía intentar instanciar clases arbitrarias de Ruby manipulando el header `type`.
17
+ * **Enhanced Routing Logs:** El Consumer ahora emite un log de nivel `DEBUG` (marcado con 🎯) que confirma de manera transparente exactamente qué Controlador y Acción se resolvieron al evaluar la petición contra el mapa de rutas.
18
+
2
19
  ## [3.1.6] - 2026-02-27
3
20
 
4
21
  ### 🐛 Bug Fixes & Router Improvements
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Active Record over AMQP.**
6
6
 
7
- BugBunny transforma la complejidad de la mensajería asíncrona (RabbitMQ) en una arquitectura **RESTful familiar** para desarrolladores Rails. Envía mensajes como si estuvieras usando Active Record y procésalos como si fueran Controladores de Rails.
7
+ BugBunny transforma la complejidad de la mensajería asíncrona (RabbitMQ) en una arquitectura **RESTful familiar** para desarrolladores Rails. Envía mensajes como si estuvieras usando Active Record y procésalos como si fueran Controladores de Rails, apoyado por un potente motor de enrutamiento declarativo.
8
8
 
9
9
  ---
10
10
 
@@ -12,14 +12,14 @@ BugBunny transforma la complejidad de la mensajería asíncrona (RabbitMQ) en un
12
12
  - [Introducción: La Filosofía](#-introducción-la-filosofía)
13
13
  - [Instalación](#-instalación)
14
14
  - [Configuración Inicial](#-configuración-inicial)
15
- - [Configuración de Infraestructura en Cascada](#-configuración-de-infraestructura-en-cascada-nuevo-v31)
15
+ - [Configuración de Infraestructura en Cascada](#-configuración-de-infraestructura-en-cascada)
16
16
  - [Modo Cliente: Recursos (ORM)](#-modo-cliente-recursos-orm)
17
17
  - [Definición y Atributos](#1-definición-y-atributos-híbridos)
18
18
  - [CRUD y Consultas](#2-crud-y-consultas-restful)
19
19
  - [Contexto Dinámico (.with)](#3-contexto-dinámico-with)
20
20
  - [Client Middleware](#4-client-middleware-interceptores)
21
21
  - [Modo Servidor: Controladores](#-modo-servidor-controladores)
22
- - [Ruteo Inteligente](#1-ruteo-inteligente)
22
+ - [Ruteo Declarativo (Rutas)](#1-ruteo-declarativo-rutas)
23
23
  - [El Controlador](#2-el-controlador)
24
24
  - [Manejo de Errores](#3-manejo-de-errores-declarativo)
25
25
  - [Observabilidad y Tracing](#-observabilidad-y-tracing)
@@ -33,7 +33,7 @@ En lugar de pensar en "Exchanges" y "Queues", BugBunny inyecta verbos HTTP (`GET
33
33
 
34
34
  * **Tu código (Cliente):** `User.create(name: 'Gabi')`
35
35
  * **Protocolo (BugBunny):** Envía `POST /users` (Header `type: users`) vía RabbitMQ.
36
- * **Worker (Servidor):** Recibe el mensaje y ejecuta `UsersController#create`.
36
+ * **Worker (Servidor):** Recibe el mensaje, evalúa tu mapa de rutas y ejecuta `UsersController#create`.
37
37
 
38
38
  ---
39
39
 
@@ -42,7 +42,7 @@ En lugar de pensar en "Exchanges" y "Queues", BugBunny inyecta verbos HTTP (`GET
42
42
  Agrega la gema a tu `Gemfile`:
43
43
 
44
44
  ```ruby
45
- gem 'bug_bunny', '~> 3.1'
45
+ gem 'bug_bunny', '~> 4.0'
46
46
  ```
47
47
 
48
48
  Ejecuta el bundle e instala los archivos base:
@@ -95,9 +95,9 @@ BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
95
95
 
96
96
  ---
97
97
 
98
- ## 🏗️ Configuración de Infraestructura en Cascada (Nuevo v3.1)
98
+ ## 🏗️ Configuración de Infraestructura en Cascada
99
99
 
100
- BugBunny v3.1 introduce un sistema de configuración jerárquico para los parámetros de RabbitMQ (como la durabilidad de Exchanges y Colas). Las opciones se resuelven en el siguiente orden de prioridad:
100
+ BugBunny utiliza un sistema de configuración jerárquico para los parámetros de RabbitMQ (como la durabilidad de Exchanges y Colas). Las opciones se resuelven en el siguiente orden de prioridad:
101
101
 
102
102
  1. **Defaults de la Gema:** Rápidos y efímeros (`durable: false`).
103
103
  2. **Configuración Global:** Definida en el inicializador para todo el entorno.
@@ -124,7 +124,7 @@ end
124
124
  Los recursos son proxies de servicios remotos. Heredan de `BugBunny::Resource`.
125
125
 
126
126
  ### 1. Definición y Atributos Híbridos
127
- BugBunny v3 es **Schema-less**. Soporta atributos tipados (ActiveModel) y dinámicos simultáneamente, además de definir su propia infraestructura.
127
+ BugBunny es **Schema-less**. Soporta atributos tipados (ActiveModel) y dinámicos simultáneamente, además de definir su propia infraestructura.
128
128
 
129
129
  ```ruby
130
130
  # app/models/manager/service.rb
@@ -188,7 +188,7 @@ Manager::Service.with(
188
188
  ```
189
189
 
190
190
  ### 4. Client Middleware (Interceptores)
191
- Intercepta peticiones de ida y respuestas de vuelta en la arquitectura del cliente.
191
+ Intercepta peticiones de ida y respuestas de vuelta en la arquitectura del cliente.
192
192
 
193
193
  **Middlewares Incluidos (Built-ins)**
194
194
  Si usas `BugBunny::Resource`, el manejo de JSON y de errores ya está integrado automáticamente. Pero si utilizas el cliente manual (`BugBunny::Client`), puedes inyectar los middlewares incluidos para no tener que parsear respuestas manualmente:
@@ -251,22 +251,44 @@ BugBunny::Middleware::RaiseError.prepend(CustomBugBunnyErrors)
251
251
 
252
252
  ## 📡 Modo Servidor: Controladores
253
253
 
254
- BugBunny implementa un **Router** que despacha mensajes a controladores basándose en el header `type` (URL) y `x-http-method`.
254
+ A partir de BugBunny v4, el enrutamiento es **declarativo** y explícito, al igual que en Rails. Se utiliza un archivo de rutas centralizado para mapear los mensajes AMQP entrantes a los Controladores adecuados.
255
255
 
256
- ### 1. Ruteo Inteligente
257
- El consumidor infiere automáticamente la acción:
256
+ ### 1. Ruteo Declarativo (Rutas)
257
+ Crea un inicializador en tu aplicación (ej. `config/initializers/bug_bunny_routes.rb`) para definir tu mapa de rutas. BugBunny usará este DSL para extraer automáticamente parámetros dinámicos de las URLs.
258
258
 
259
- | Verbo AMQP | Path (Header `type`) | Controlador | Acción |
260
- | :--- | :--- | :--- | :--- |
261
- | `GET` | `services` | `ServicesController` | `index` |
262
- | `GET` | `services/123` | `ServicesController` | `show` |
263
- | `POST` | `services` | `ServicesController` | `create` |
264
- | `PUT` | `services/123` | `ServicesController` | `update` |
265
- | `DELETE` | `services/123` | `ServicesController` | `destroy` |
266
- | `POST` | `services/123/restart` | `ServicesController` | `restart` (Custom) |
259
+ ```ruby
260
+ # config/initializers/bug_bunny_routes.rb
261
+
262
+ BugBunny.routes.draw do
263
+ # 1. Colecciones Básicas y Filtrado
264
+ # Genera rutas para index, show y update únicamente
265
+ resources :services, only: [:index, :show, :update]
266
+
267
+ # 2. Rutas Anidadas (Member y Collection)
268
+ resources :nodes, except: [:create, :destroy] do
269
+ # Member inyecta el parámetro :id (ej. PUT nodes/:id/drain)
270
+ member do
271
+ put :drain
272
+ post :restart
273
+ end
274
+
275
+ # Collection opera sobre el conjunto (ej. GET nodes/stats)
276
+ collection do
277
+ get :stats
278
+ end
279
+ end
280
+
281
+ # 3. Rutas estáticas (Colecciones o Acciones Custom)
282
+ get 'health_checks/up', to: 'health_checks#up'
283
+
284
+ # 4. Extracción automática de variables dinámicas profundas
285
+ get 'api/v1/clusters/:cluster_id/nodes/:node_id/metrics', to: 'api/v1/metrics#show'
286
+ end
287
+ ```
267
288
 
268
289
  ### 2. El Controlador
269
290
  Ubicación: `app/rabbit/controllers/`.
291
+ Los parámetros declarados en el archivo de rutas (como `:id` o `:cluster_id`) estarán disponibles automáticamente dentro del hash `params` de tu controlador.
270
292
 
271
293
  ```ruby
272
294
  class ServicesController < BugBunny::Controller
@@ -279,7 +301,7 @@ class ServicesController < BugBunny::Controller
279
301
  end
280
302
 
281
303
  def create
282
- # BugBunny envuelve los params automáticamente (param_key)
304
+ # BugBunny envuelve los params automáticamente basándose en el resource_name
283
305
  # params[:service] => { name: '...', replicas: ... }
284
306
  if Service.create(params[:service])
285
307
  render status: 201, json: { status: 'created' }
@@ -291,7 +313,7 @@ class ServicesController < BugBunny::Controller
291
313
  private
292
314
 
293
315
  def set_service
294
- # params[:id] se extrae del Path
316
+ # params[:id] es extraído e inyectado por el BugBunny.routes
295
317
  @service = Service.find(params[:id])
296
318
  end
297
319
  end
@@ -317,16 +339,14 @@ end
317
339
 
318
340
  ## 🔎 Observabilidad y Tracing
319
341
 
320
- > **Novedad v3.1:** BugBunny implementa Distributed Tracing nativo.
321
-
322
- El `correlation_id` se mantiene intacto a través de toda la cadena: `Producer -> RabbitMQ -> Consumer -> Controller`.
342
+ BugBunny implementa Distributed Tracing nativo. El `correlation_id` se mantiene intacto a través de toda la cadena: `Producer -> RabbitMQ -> Consumer -> Controller`.
323
343
 
324
344
  ### 1. Logs Automáticos (Consumer)
325
345
  No requiere configuración. El worker envuelve la ejecución en bloques de log etiquetados con el UUID.
326
346
 
327
347
  ```text
328
- [d41d8cd9...] [Consumer] Listening on queue...
329
- [d41d8cd9...] [API] Processing ServicesController#create...
348
+ [d41d8cd9...] [BugBunny::Consumer] 📥 Received PUT "/nodes/4bv445vgc158hk" | RK: 'dbu55...'
349
+ [d41d8cd9...] [BugBunny::Consumer] 🎯 Routed to Rabbit::Controllers::NodesController#drain
330
350
  ```
331
351
 
332
352
  ### 2. Logs de Negocio (Controller)
@@ -378,7 +398,7 @@ Para máxima velocidad, BugBunny usa `amq.rabbitmq.reply-to`.
378
398
  * **Recomendación:** Diseña tus acciones RPC (`POST`, `PUT`) para que sean **idempotentes** (seguras de reintentar ante un timeout).
379
399
 
380
400
  ### Seguridad
381
- El Router incluye protecciones contra **Remote Code Execution (RCE)**. Verifica estrictamente que la clase instanciada herede de `BugBunny::Controller` antes de ejecutarla, impidiendo la inyección de clases arbitrarias de Ruby vía el header `type`.
401
+ El Router incluye protecciones contra **Remote Code Execution (RCE)**. El Consumer verifica estrictamente que el Controlador resuelto a través del archivo de rutas herede de `BugBunny::Controller` antes de ejecutarla, impidiendo la inyección de clases arbitrarias. Además, las llamadas a rutas no registradas fallan rápido con un `404 Not Found`.
382
402
 
383
403
  ### Health Checks en Docker Swarm / Kubernetes
384
404
  Dado que un Worker se ejecuta en segundo plano sin exponer un servidor web tradicional, orquestadores como Docker Swarm o Kubernetes no pueden usar un endpoint HTTP para verificar si el proceso está saludable.
@@ -15,7 +15,7 @@ module BugBunny
15
15
  # Sus responsabilidades son:
16
16
  # 1. Escuchar una cola específica.
17
17
  # 2. Deserializar el mensaje y sus headers.
18
- # 3. Enrutar el mensaje a un Controlador (`BugBunny::Controller`) basándose en el "path" y el verbo HTTP.
18
+ # 3. Consultar el mapa global `BugBunny.routes` para enrutar el mensaje a un Controlador.
19
19
  # 4. Gestionar el ciclo de respuesta RPC (Request-Response) para evitar timeouts en el cliente.
20
20
  #
21
21
  # @example Suscripción manual
@@ -99,16 +99,16 @@ module BugBunny
99
99
 
100
100
  private
101
101
 
102
- # Procesa un mensaje individual recibido de la cola.
102
+ # Procesa un mensaje individual recibido de la cola orquestando el ruteo declarativo.
103
103
  #
104
- # Realiza la orquestación completa: Parsing -> Routing -> Ejecución -> Respuesta.
104
+ # Realiza la orquestación completa: Parsing -> Reconocimiento de Ruta -> Ejecución -> Respuesta.
105
105
  #
106
106
  # @param delivery_info [Bunny::DeliveryInfo] Metadatos de entrega (tag, redelivered, etc).
107
107
  # @param properties [Bunny::MessageProperties] Headers y propiedades AMQP (reply_to, correlation_id).
108
108
  # @param body [String] El payload crudo del mensaje.
109
109
  # @return [void]
110
110
  def process_message(delivery_info, properties, body)
111
- # 1. Validación de Headers
111
+ # 1. Validación de Headers (URL path)
112
112
  path = properties.type || (properties.headers && properties.headers['path'])
113
113
 
114
114
  if path.nil? || path.empty?
@@ -119,142 +119,91 @@ module BugBunny
119
119
 
120
120
  # 2. Recuperación Robusta del Verbo HTTP
121
121
  headers_hash = properties.headers || {}
122
- http_method = headers_hash['x-http-method'] || headers_hash['method'] || 'GET'
122
+ http_method = (headers_hash['x-http-method'] || headers_hash['method'] || 'GET').to_s.upcase
123
123
 
124
- # 3. Router: Inferencia de Controlador y Acción
125
- route_info = router_dispatch(http_method, path)
126
-
127
- BugBunny.configuration.logger.info("[BugBunny::Consumer] 📥 Started #{http_method} \"/#{path}\" for Routing Key: #{delivery_info.routing_key}")
124
+ BugBunny.configuration.logger.info("[BugBunny::Consumer] 📥 Received #{http_method} \"/#{path}\" | RK: '#{delivery_info.routing_key}'")
128
125
  BugBunny.configuration.logger.debug("[BugBunny::Consumer] 📦 Body: #{body.truncate(200)}")
129
126
 
130
- request_metadata = {
131
- type: path,
132
- http_method: http_method,
133
- controller: route_info[:controller],
134
- action: route_info[:action],
135
- id: route_info[:id],
136
- query_params: route_info[:params],
137
- content_type: properties.content_type,
138
- correlation_id: properties.correlation_id,
139
- reply_to: properties.reply_to
140
- }.merge(properties.headers)
127
+ # ===================================================================
128
+ # 3. Ruteo Declarativo
129
+ # ===================================================================
130
+ uri = URI.parse("http://dummy/#{path}")
141
131
 
142
- # 4. Instanciación Dinámica del Controlador
143
- # Utilizamos el namespace configurado en lugar de hardcodear "Rabbit::Controllers"
144
- begin
145
- namespace = BugBunny.configuration.controller_namespace
146
- controller_name = route_info[:controller].camelize
132
+ # Extraemos query params (ej. /nodes?status=active)
133
+ query_params = uri.query ? Rack::Utils.parse_nested_query(uri.query) : {}
134
+ if defined?(ActiveSupport::HashWithIndifferentAccess)
135
+ query_params = query_params.with_indifferent_access
136
+ end
147
137
 
148
- # Construcción: "Messaging::Handlers" + "::" + "Users"
149
- controller_class_name = "#{namespace}::#{controller_name}Controller"
138
+ # Le preguntamos al motor de rutas global quién debe manejar esto
139
+ route_info = BugBunny.routes.recognize(http_method, uri.path)
150
140
 
151
- controller_class = controller_class_name.constantize
141
+ if route_info.nil?
142
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ No route matches [#{http_method}] \"/#{uri.path}\"")
143
+ handle_fatal_error(properties, 404, "Not Found", "No route matches [#{http_method}] \"/#{uri.path}\"")
144
+ session.channel.reject(delivery_info.delivery_tag, false)
145
+ return
146
+ end
152
147
 
153
- unless controller_class < BugBunny::Controller
154
- raise BugBunny::SecurityError, "Class #{controller_class} is not a valid BugBunny Controller"
155
- end
156
- rescue NameError => _e
157
- BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Controller not found: #{controller_class_name} (Path: #{path})")
148
+ # Fusionamos los parámetros extraídos de la URL (ej. :id) con los query_params
149
+ final_params = query_params.merge(route_info[:params])
150
+
151
+ # ===================================================================
152
+ # 4. Instanciación del Controlador
153
+ # ===================================================================
154
+ namespace = BugBunny.configuration.controller_namespace
155
+ controller_name = route_info[:controller].camelize
156
+ controller_class_name = "#{namespace}::#{controller_name}Controller"
157
+
158
+ begin
159
+ controller_class = controller_class_name.constantize
160
+ rescue NameError
161
+ BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Controller class not found: #{controller_class_name}")
158
162
  handle_fatal_error(properties, 404, "Not Found", "Controller #{controller_class_name} not found")
159
163
  session.channel.reject(delivery_info.delivery_tag, false)
160
164
  return
161
165
  end
162
166
 
163
- # 5. Ejecución del Pipeline (Middleware + Acción)
167
+ # Verificación estricta de Seguridad (RCE Prevention)
168
+ unless controller_class < BugBunny::Controller
169
+ BugBunny.configuration.logger.error("[BugBunny::Consumer] ⛔ Security Alert: #{controller_class} is not a valid BugBunny Controller")
170
+ handle_fatal_error(properties, 403, "Forbidden", "Invalid Controller Class")
171
+ session.channel.reject(delivery_info.delivery_tag, false)
172
+ return
173
+ end
174
+
175
+ BugBunny.configuration.logger.debug("[BugBunny::Consumer] 🎯 Routed to #{controller_class_name}##{route_info[:action]}")
176
+
177
+ request_metadata = {
178
+ type: path,
179
+ http_method: http_method,
180
+ controller: route_info[:controller],
181
+ action: route_info[:action],
182
+ id: final_params['id'] || final_params[:id],
183
+ query_params: final_params,
184
+ content_type: properties.content_type,
185
+ correlation_id: properties.correlation_id,
186
+ reply_to: properties.reply_to
187
+ }.merge(headers_hash)
188
+
189
+ # ===================================================================
190
+ # 5. Ejecución y Respuesta
191
+ # ===================================================================
164
192
  response_payload = controller_class.call(headers: request_metadata, body: body)
165
193
 
166
- # 6. Respuesta RPC
167
194
  if properties.reply_to
168
195
  reply(response_payload, properties.reply_to, properties.correlation_id)
169
196
  end
170
197
 
171
- # 7. Acknowledge
172
198
  session.channel.ack(delivery_info.delivery_tag)
173
199
 
174
200
  rescue StandardError => e
175
201
  BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Execution Error (#{e.class}): #{e.message}")
202
+ BugBunny.configuration.logger.debug(e.backtrace.join("\n"))
176
203
  handle_fatal_error(properties, 500, "Internal Server Error", e.message)
177
204
  session.channel.reject(delivery_info.delivery_tag, false)
178
205
  end
179
206
 
180
- # Interpreta la URL y el verbo para decidir qué controlador ejecutar.
181
- #
182
- # Implementa un Router Heurístico que soporta namespaces y acciones custom
183
- # buscando dinámicamente el ID en la ruta mediante Regex y Fallback Semántico.
184
- #
185
- # @param method [String] Verbo HTTP (GET, POST, etc).
186
- # @param path [String] URL virtual del recurso (ej: 'foo/bar/algo/13/test').
187
- # @return [Hash] Estructura con keys {:controller, :action, :id, :params}.
188
- def router_dispatch(method, path)
189
- uri = URI.parse("http://dummy/#{path}")
190
- segments = uri.path.split('/').reject(&:empty?)
191
-
192
- query_params = uri.query ? Rack::Utils.parse_nested_query(uri.query) : {}
193
- if defined?(ActiveSupport::HashWithIndifferentAccess)
194
- query_params = query_params.with_indifferent_access
195
- end
196
-
197
- # 1. Acción Built-in: Health Check Global (/up o /api/up)
198
- if segments.last == 'up' && method.to_s.upcase == 'GET'
199
- ctrl = segments.size > 1 ? segments[0...-1].join('/') : 'application'
200
- return { controller: ctrl, action: 'up', id: nil, params: query_params }
201
- end
202
-
203
- # 2. Búsqueda dinámica del ID (Heurística por Regex)
204
- # Patrón: Enteros, UUIDs, o Hashes largos (Docker Swarm 25 chars, Mongo 24 chars)
205
- id_pattern = /^(?:\d+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[a-zA-Z0-9_-]{20,})$/
206
-
207
- # FIX: Usamos rindex (de derecha a izquierda) para evitar falsos positivos con namespaces como 'v1'
208
- id_index = segments.rindex { |s| s.match?(id_pattern) }
209
-
210
- # 3. Fallback Semántico Posicional
211
- # Si el regex no detectó el ID (ej: ID corto como "node-1"), pero la semántica HTTP
212
- # indica que es una operación singular (PUT/DELETE/GET), asumimos que el último segmento es el ID.
213
- if id_index.nil? && segments.size >= 2
214
- last_segment = segments.last
215
- method_up = method.to_s.upcase
216
-
217
- is_member_verb = %w[PUT PATCH DELETE].include?(method_up)
218
- # En GET, nos aseguramos que la última palabra no sea una acción estándar de REST
219
- is_get_member = method_up == 'GET' && !%w[index new edit up action].include?(last_segment)
220
-
221
- if is_member_verb || is_get_member
222
- # Si tiene 3 o más segmentos (ej. nodes/node-1/stats), el ID no está al final.
223
- # Este fallback asume que para IDs raros, el formato clásico es recurso/id
224
- id_index = segments.size - 1
225
- end
226
- end
227
-
228
- # 4. Asignación de variables según escenario
229
- if id_index
230
- # ESCENARIO A: Ruta Miembro (ej. nodes/4bv445vgc158hk4twlxmdjo0v/stats)
231
- controller_name = segments[0...id_index].join('/')
232
- id = segments[id_index]
233
- action = segments[id_index + 1] # Puede ser nil si no hay acción extra al final
234
- else
235
- # ESCENARIO B: Ruta Colección (ej. api/v1/nodes)
236
- controller_name = segments.join('/')
237
- id = nil
238
- action = nil
239
- end
240
-
241
- # 5. Inferimos la acción clásica de Rails si no hay una explícita
242
- unless action
243
- action = case method.to_s.upcase
244
- when 'GET' then id ? 'show' : 'index'
245
- when 'POST' then 'create'
246
- when 'PUT', 'PATCH' then 'update'
247
- when 'DELETE' then 'destroy'
248
- else id ? 'show' : 'index'
249
- end
250
- end
251
-
252
- # 6. Inyectamos el ID en los parámetros para fácil acceso en el Controlador
253
- query_params['id'] = id if id
254
-
255
- { controller: controller_name, action: action, id: id, params: query_params }
256
- end
257
-
258
207
  # Envía una respuesta al cliente RPC utilizando Direct Reply-to.
259
208
  #
260
209
  # @param payload [Hash] Cuerpo de la respuesta ({ status: ..., body: ... }).
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugBunny
4
+ module Routing
5
+ # Representa una ruta individual dentro del mapa de rutas de BugBunny.
6
+ #
7
+ # Esta clase se encarga de compilar un patrón de URL (ej. 'users/:id') en una
8
+ # Expresión Regular capaz de evaluar coincidencias y extraer los parámetros
9
+ # dinámicos nombrados de forma automática.
10
+ class Route
11
+ # @return [String] El verbo HTTP de la ruta (GET, POST, PUT, DELETE).
12
+ attr_reader :http_method
13
+
14
+ # @return [String] El patrón original de la ruta (ej. 'nodes/:node_id/metrics').
15
+ attr_reader :path_pattern
16
+
17
+ # @return [String] El nombre del controlador en formato snake_case (ej. 'api/v1/metrics').
18
+ attr_reader :controller
19
+
20
+ # @return [String] El nombre de la acción a ejecutar (ej. 'show').
21
+ attr_reader :action
22
+
23
+ # Inicializa una nueva Ruta compilando su Expresión Regular.
24
+ #
25
+ # @param http_method [String, Symbol] Verbo HTTP (ej. :get, 'POST').
26
+ # @param path_pattern [String] Patrón de la URL. Los parámetros dinámicos deben iniciar con ':' (ej. 'users/:id').
27
+ # @param to [String] Destino en formato 'controlador#accion' (ej. 'users#show').
28
+ # @raise [ArgumentError] Si el formato del destino `to` es inválido.
29
+ def initialize(http_method, path_pattern, to:)
30
+ @http_method = http_method.to_s.upcase
31
+ @path_pattern = normalize_path(path_pattern)
32
+
33
+ parse_destination!(to)
34
+ compile_regex!
35
+ end
36
+
37
+ # Evalúa si una petición entrante coincide con esta ruta.
38
+ #
39
+ # @param method [String] El verbo HTTP entrante.
40
+ # @param path [String] La URL entrante a evaluar.
41
+ # @return [Boolean] `true` si hace match, `false` en caso contrario.
42
+ def match?(method, path)
43
+ return false unless @http_method == method.to_s.upcase
44
+
45
+ normalized_path = normalize_path(path)
46
+ @regex.match?(normalized_path)
47
+ end
48
+
49
+ # Extrae los parámetros dinámicos de una URL que hizo coincidencia con el patrón.
50
+ #
51
+ # @param path [String] La URL entrante (ej. 'users/123').
52
+ # @return [Hash] Diccionario con las variables extraídas (ej. { 'id' => '123' }).
53
+ # @example
54
+ # route = Route.new('GET', 'users/:id', to: 'users#show')
55
+ # route.extract_params('users/42') # => { 'id' => '42' }
56
+ def extract_params(path)
57
+ normalized_path = normalize_path(path)
58
+ match_data = @regex.match(normalized_path)
59
+
60
+ return {} unless match_data
61
+
62
+ # match_data.named_captures devuelve un Hash con las variables que definimos en la Regex
63
+ match_data.named_captures
64
+ end
65
+
66
+ private
67
+
68
+ # Elimina las barras '/' al principio y al final para evitar problemas de formato.
69
+ #
70
+ # @param path [String] URL cruda.
71
+ # @return [String] URL normalizada.
72
+ def normalize_path(path)
73
+ path.to_s.gsub(%r{^/|/$}, '')
74
+ end
75
+
76
+ # Parsea el string 'controlador#accion' y lo asigna a las variables de instancia.
77
+ #
78
+ # @param destination [String] El destino declarado por el usuario.
79
+ def parse_destination!(destination)
80
+ parts = destination.split('#')
81
+ if parts.size != 2
82
+ raise ArgumentError, "Destino inválido: '#{destination}'. Debe seguir el formato 'controlador#accion'."
83
+ end
84
+
85
+ @controller = parts[0]
86
+ @action = parts[1]
87
+ end
88
+
89
+ # Transforma el string 'users/:id' en una Regex de Ruby con "Named Captures".
90
+ # Reemplaza los :param por (?<param>[^/]+) que captura todo hasta la siguiente barra.
91
+ def compile_regex!
92
+ # Si la ruta es estática ('swarm/info'), la regex simplemente será /^swarm\/info$/
93
+ # Si tiene variables ('nodes/:id'), convertimos el :id en un grupo de captura.
94
+ pattern = @path_pattern.gsub(/:([a-zA-Z0-9_]+)/) do |match|
95
+ param_name = match.delete(':')
96
+ "(?<#{param_name}>[^/]+)"
97
+ end
98
+
99
+ @regex = Regexp.new("^#{pattern}$")
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route'
4
+
5
+ module BugBunny
6
+ module Routing
7
+ # Gestiona la colección de rutas de la aplicación y expone el DSL de configuración.
8
+ #
9
+ # Actúa como el motor principal del enrutador. Permite definir rutas de forma
10
+ # declarativa (estilo Rails) e incluye macros convenientes como `resources`,
11
+ # soportando bloques anidados `member` y `collection`.
12
+ #
13
+ # @example Configuración del DSL en un inicializador
14
+ # route_set = RouteSet.new
15
+ # route_set.draw do
16
+ # get 'ping', to: 'health#ping'
17
+ #
18
+ # resources :nodes, except: [:create, :destroy] do
19
+ # member do
20
+ # put :drain
21
+ # end
22
+ # collection do
23
+ # get :stats
24
+ # end
25
+ # end
26
+ # end
27
+ class RouteSet
28
+ # @return [Array<BugBunny::Routing::Route>] Lista de rutas registradas y compiladas.
29
+ attr_reader :routes
30
+
31
+ # Inicializa un conjunto de rutas vacío y prepara el stack de scopes.
32
+ def initialize
33
+ @routes = []
34
+ @scopes = [] # Pila para rastrear el contexto (namespaces, resources, members)
35
+ end
36
+
37
+ # Evalúa un bloque de código en el contexto de esta instancia para construir el mapa.
38
+ # Utiliza `instance_eval` para exponer el DSL directamente.
39
+ #
40
+ # @yield Bloque de configuración conteniendo las definiciones de ruteo.
41
+ # @return [void]
42
+ def draw(&block)
43
+ instance_eval(&block)
44
+ end
45
+
46
+ # Borra todas las rutas registradas y limpia los scopes.
47
+ # Es útil para entornos de pruebas (testing) o recarga en caliente (hot-reloading).
48
+ #
49
+ # @return [void]
50
+ def clear!
51
+ @routes.clear
52
+ @scopes.clear
53
+ end
54
+
55
+ # @!group DSL de Verbos HTTP
56
+
57
+ # Registra una ruta para el verbo GET.
58
+ # @param path [String, Symbol] Patrón de la URL.
59
+ # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
60
+ def get(path, to: nil); add_route('GET', path, to: to); end
61
+
62
+ # Registra una ruta para el verbo POST.
63
+ # @param path [String, Symbol] Patrón de la URL.
64
+ # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
65
+ def post(path, to: nil); add_route('POST', path, to: to); end
66
+
67
+ # Registra una ruta para el verbo PUT.
68
+ # @param path [String, Symbol] Patrón de la URL.
69
+ # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
70
+ def put(path, to: nil); add_route('PUT', path, to: to); end
71
+
72
+ # Registra una ruta para el verbo PATCH.
73
+ # @param path [String, Symbol] Patrón de la URL.
74
+ # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
75
+ def patch(path, to: nil); add_route('PATCH', path, to: to); end
76
+
77
+ # Registra una ruta para el verbo DELETE.
78
+ # @param path [String, Symbol] Patrón de la URL.
79
+ # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
80
+ def delete(path, to: nil); add_route('DELETE', path, to: to); end
81
+
82
+ # @!endgroup
83
+
84
+ # Macro que genera automáticamente las rutas CRUD para un recurso RESTful.
85
+ # Soporta filtrado mediante `only` y `except`, y acepta un bloque para rutas anidadas.
86
+ #
87
+ # Mapea las acciones: index (GET), show (GET /:id), create (POST),
88
+ # update (PUT/PATCH /:id) y destroy (DELETE /:id).
89
+ #
90
+ # @param name [Symbol, String] Nombre del recurso en plural (ej. :nodes).
91
+ # @param only [Array<Symbol>, Symbol, nil] Acciones a incluir exclusivamente.
92
+ # @param except [Array<Symbol>, Symbol, nil] Acciones a excluir.
93
+ # @yield Bloque para definir rutas `member` o `collection`.
94
+ # @return [void]
95
+ def resources(name, only: nil, except: nil, &block)
96
+ resource_path = name.to_s
97
+
98
+ # Todas las acciones estándar disponibles
99
+ actions = [:index, :show, :create, :update, :destroy]
100
+
101
+ # Aplicamos los filtros si existen
102
+ if only
103
+ actions &= Array(only).map(&:to_sym)
104
+ elsif except
105
+ actions -= Array(except).map(&:to_sym)
106
+ end
107
+
108
+ # Rutas estándar (Fuera del scope anidado)
109
+ get resource_path, to: "#{resource_path}#index" if actions.include?(:index)
110
+ post resource_path, to: "#{resource_path}#create" if actions.include?(:create)
111
+ get "#{resource_path}/:id", to: "#{resource_path}#show" if actions.include?(:show)
112
+ put "#{resource_path}/:id", to: "#{resource_path}#update" if actions.include?(:update)
113
+ patch "#{resource_path}/:id", to: "#{resource_path}#update" if actions.include?(:update)
114
+ delete "#{resource_path}/:id", to: "#{resource_path}#destroy" if actions.include?(:destroy)
115
+
116
+ # Si se pasa un bloque, abrimos un Scope de Recurso para rutas anidadas
117
+ if block_given?
118
+ with_scope({ type: :resource, name: resource_path }) do
119
+ instance_eval(&block)
120
+ end
121
+ end
122
+ end
123
+
124
+ # Define rutas aplicables a un miembro específico del recurso (Requieren un ID).
125
+ #
126
+ # Al usar este bloque, el router antepondrá automáticamente el nombre del recurso
127
+ # y el parámetro `:id` a la URL generada, e inferirá el controlador base.
128
+ #
129
+ # @yield Bloque conteniendo definiciones de rutas.
130
+ # @raise [ArgumentError] Si se llama fuera de un bloque `resources`.
131
+ # @return [void]
132
+ # @example
133
+ # resources :nodes do
134
+ # member do
135
+ # put :drain # Genera: PUT nodes/:id/drain => NodesController#drain
136
+ # end
137
+ # end
138
+ def member(&block)
139
+ unless current_scope[:type] == :resource
140
+ raise ArgumentError, "El bloque 'member' solo puede usarse dentro de un bloque 'resources'"
141
+ end
142
+
143
+ with_scope({ type: :member }) do
144
+ instance_eval(&block)
145
+ end
146
+ end
147
+
148
+ # Define rutas aplicables a la colección completa del recurso (Sin ID).
149
+ #
150
+ # Al usar este bloque, el router antepondrá automáticamente el nombre del recurso
151
+ # a la URL generada e inferirá el controlador base.
152
+ #
153
+ # @yield Bloque conteniendo definiciones de rutas.
154
+ # @raise [ArgumentError] Si se llama fuera de un bloque `resources`.
155
+ # @return [void]
156
+ # @example
157
+ # resources :nodes do
158
+ # collection do
159
+ # get :stats # Genera: GET nodes/stats => NodesController#stats
160
+ # end
161
+ # end
162
+ def collection(&block)
163
+ unless current_scope[:type] == :resource
164
+ raise ArgumentError, "El bloque 'collection' solo puede usarse dentro de un bloque 'resources'"
165
+ end
166
+
167
+ with_scope({ type: :collection }) do
168
+ instance_eval(&block)
169
+ end
170
+ end
171
+
172
+ # Evalúa una petición entrante contra el mapa de rutas.
173
+ #
174
+ # Recorre las rutas en el orden en que fueron definidas. La primera ruta que
175
+ # haga match será la ganadora. Retorna los datos necesarios para instanciar
176
+ # el controlador e inyectarle los parámetros dinámicos extraídos.
177
+ #
178
+ # @param method [String] Verbo HTTP entrante (ej. 'GET').
179
+ # @param path [String] URL entrante (ej. 'nodes/123/drain').
180
+ # @return [Hash, nil] Hash con `:controller`, `:action` y `:params`, o `nil` si no hay match.
181
+ def recognize(method, path)
182
+ @routes.each do |route|
183
+ if route.match?(method, path)
184
+ extracted_params = route.extract_params(path)
185
+
186
+ return {
187
+ controller: route.controller,
188
+ action: route.action,
189
+ params: extracted_params
190
+ }
191
+ end
192
+ end
193
+
194
+ # Si llegamos aquí, es un 404 seguro.
195
+ nil
196
+ end
197
+
198
+ private
199
+
200
+ # Instancia y almacena la ruta resolviendo la URL final y el controlador según el scope.
201
+ #
202
+ # @param method [String] Verbo HTTP.
203
+ # @param path [String, Symbol] Ruta declarada.
204
+ # @param to [String, nil] Destino declarado.
205
+ # @raise [ArgumentError] Si no se puede inferir el destino y no se provee uno.
206
+ # @api private
207
+ def add_route(method, path, to: nil)
208
+ final_path = path.to_s
209
+ final_to = to
210
+
211
+ # Inferimos rutas basadas en el Scope Activo
212
+ if in_scope?(:member)
213
+ resource = parent_resource_name
214
+ final_path = "#{resource}/:id/#{path}"
215
+ final_to ||= "#{resource}##{path}"
216
+ elsif in_scope?(:collection) || in_scope?(:resource)
217
+ resource = parent_resource_name
218
+ final_path = "#{resource}/#{path}"
219
+ final_to ||= "#{resource}##{path}"
220
+ end
221
+
222
+ if final_to.nil?
223
+ raise ArgumentError, "Falta el destino 'to:' para la ruta #{method} '#{final_path}'. Usa la sintaxis 'controlador#accion'"
224
+ end
225
+
226
+ @routes << Route.new(method, final_path, to: final_to)
227
+ end
228
+
229
+ # --- LÓGICA DE SCOPES INTERNOS ---
230
+
231
+ # Abre un nuevo contexto de alcance temporal.
232
+ #
233
+ # @param scope [Hash] Información del nuevo alcance.
234
+ # @yield Bloque a ejecutar dentro de este alcance.
235
+ # @api private
236
+ def with_scope(scope)
237
+ @scopes << scope
238
+ yield
239
+ ensure
240
+ @scopes.pop
241
+ end
242
+
243
+ # @return [Hash] El scope activo actualmente.
244
+ # @api private
245
+ def current_scope
246
+ @scopes.last || {}
247
+ end
248
+
249
+ # @param type [Symbol] El tipo de scope a verificar.
250
+ # @return [Boolean] Si estamos dentro de un scope del tipo especificado.
251
+ # @api private
252
+ def in_scope?(type)
253
+ current_scope[:type] == type
254
+ end
255
+
256
+ # Busca hacia atrás en la pila de scopes para encontrar el nombre del recurso padre.
257
+ #
258
+ # @return [String, nil] El nombre del recurso o nil si no se encuentra.
259
+ # @api private
260
+ def parent_resource_name
261
+ @scopes.reverse_each do |scope|
262
+ return scope[:name] if scope[:type] == :resource
263
+ end
264
+ nil
265
+ end
266
+ end
267
+ end
268
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "3.1.6"
4
+ VERSION = "4.0.0"
5
5
  end
data/lib/bug_bunny.rb CHANGED
@@ -5,6 +5,7 @@ require 'logger'
5
5
  require_relative 'bug_bunny/version'
6
6
  require_relative 'bug_bunny/exception'
7
7
  require_relative 'bug_bunny/configuration'
8
+ require_relative 'bug_bunny/routing/route_set'
8
9
  require_relative 'bug_bunny/middleware/base'
9
10
  require_relative 'bug_bunny/middleware/stack'
10
11
  require_relative 'bug_bunny/middleware/raise_error'
@@ -27,6 +28,13 @@ module BugBunny
27
28
 
28
29
  # @return [Bunny::Session, nil] La conexión global (Singleton) usada por procesos Rails.
29
30
  attr_accessor :global_connection
31
+
32
+ attr_writer :routes
33
+ end
34
+
35
+ # @return [BugBunny::Routing::RouteSet] El motor global de enrutamiento.
36
+ def self.routes
37
+ @routes ||= Routing::RouteSet.new
30
38
  end
31
39
 
32
40
  # Configura la librería BugBunny.
@@ -59,3 +59,20 @@ BugBunny.configure do |config|
59
59
  # * Valor alto (10-50): Mayor throughput, pero riesgo de sobrecargar un solo worker lento.
60
60
  config.channel_prefetch = 10
61
61
  end
62
+
63
+ # ==========================================
64
+ # 🗺️ Enrutamiento Declarativo (Router)
65
+ # ==========================================
66
+ # Define cómo se mapean las rutas de los mensajes entrantes a tus controladores.
67
+ # Funciona de manera similar al routes.rb de Rails.
68
+
69
+ # BugBunny.routes.draw do
70
+ # # Macro para generar rutas CRUD estándar (index, show, create, update, destroy)
71
+ # resources :services
72
+ #
73
+ # # Rutas estáticas o custom
74
+ # get 'health_checks/up', to: 'health_checks#up'
75
+ #
76
+ # # Rutas con parámetros dinámicos (:id, :cluster_id, etc.)
77
+ # put 'nodes/:id/drain', to: 'nodes#drain'
78
+ # end
data/test/test_helper.rb CHANGED
@@ -107,3 +107,14 @@ module IntegrationHelper
107
107
  worker_thread&.kill
108
108
  end
109
109
  end
110
+
111
+ # test/test_helper.rb (Al final del archivo)
112
+
113
+ BugBunny.routes.draw do
114
+ # Rutas necesarias para infrastructure_test.rb
115
+ resources :ping
116
+
117
+ # Rutas necesarias para manual_client_test.rb
118
+ get 'echo', to: 'echo#index'
119
+ post 'test', to: 'test#index' # o el controlador que maneje 'test'
120
+ 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.1.6
4
+ version: 4.0.0
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-27 00:00:00.000000000 Z
11
+ date: 2026-03-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -247,6 +247,8 @@ files:
247
247
  - lib/bug_bunny/railtie.rb
248
248
  - lib/bug_bunny/request.rb
249
249
  - lib/bug_bunny/resource.rb
250
+ - lib/bug_bunny/routing/route.rb
251
+ - lib/bug_bunny/routing/route_set.rb
250
252
  - lib/bug_bunny/session.rb
251
253
  - lib/bug_bunny/version.rb
252
254
  - lib/generators/bug_bunny/install/install_generator.rb