bug_bunny 2.0.2 → 3.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 +4 -4
- data/CHANGELOG.md +23 -1
- data/README.md +222 -132
- data/bin_client.rb +51 -0
- data/bin_suite.rb +77 -0
- data/bin_worker.rb +20 -0
- data/initializer_example.rb +27 -0
- data/lib/bug_bunny/client.rb +119 -0
- data/lib/bug_bunny/config.rb +74 -3
- data/lib/bug_bunny/consumer.rb +151 -0
- data/lib/bug_bunny/controller.rb +74 -49
- data/lib/bug_bunny/exception.rb +81 -14
- data/lib/bug_bunny/middleware/json_response.rb +74 -0
- data/lib/bug_bunny/middleware/raise_error.rb +71 -0
- data/lib/bug_bunny/middleware/stack.rb +50 -0
- data/lib/bug_bunny/producer.rb +130 -0
- data/lib/bug_bunny/rabbit.rb +70 -300
- data/lib/bug_bunny/railtie.rb +54 -0
- data/lib/bug_bunny/request.rb +128 -0
- data/lib/bug_bunny/resource.rb +361 -156
- data/lib/bug_bunny/session.rb +82 -0
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +104 -16
- data/lib/generators/bug_bunny/install/install_generator.rb +48 -0
- data/lib/generators/bug_bunny/install/templates/initializer.rb +61 -0
- data/test_controller.rb +49 -0
- data/test_helper.rb +20 -0
- data/test_resource.rb +22 -0
- metadata +178 -4
- data/lib/bug_bunny/publisher.rb +0 -108
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# lib/bug_bunny/middleware/json_response.rb
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module BugBunny
|
|
5
|
+
module Middleware
|
|
6
|
+
# Middleware encargado de parsear automáticamente el cuerpo de la respuesta.
|
|
7
|
+
#
|
|
8
|
+
# Este middleware intercepta la respuesta proveniente del servicio remoto. Si el `body`
|
|
9
|
+
# es un String JSON válido, lo convierte a un Hash o Array de Ruby.
|
|
10
|
+
#
|
|
11
|
+
# **Integración con Rails:**
|
|
12
|
+
# Si `ActiveSupport` está cargado en el entorno, convierte los Hashes resultantes
|
|
13
|
+
# a `HashWithIndifferentAccess`. Esto permite a los desarrolladores acceder a las claves
|
|
14
|
+
# usando símbolos o strings indistintamente (ej: `body[:id]` o `body['id']`),
|
|
15
|
+
# comportamiento estándar en Rails.
|
|
16
|
+
#
|
|
17
|
+
# @example Uso en la configuración del cliente
|
|
18
|
+
# client = BugBunny::Client.new(pool: POOL) do |conn|
|
|
19
|
+
# # Se recomienda ponerlo después de RaiseError para tener el body parseado en las excepciones
|
|
20
|
+
# conn.use BugBunny::Middleware::RaiseError
|
|
21
|
+
# conn.use BugBunny::Middleware::JsonResponse
|
|
22
|
+
# end
|
|
23
|
+
class JsonResponse
|
|
24
|
+
# Inicializa el middleware.
|
|
25
|
+
#
|
|
26
|
+
# @param app [Object] El siguiente middleware o el productor final en el stack.
|
|
27
|
+
def initialize(app)
|
|
28
|
+
@app = app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ejecuta el middleware.
|
|
32
|
+
#
|
|
33
|
+
# Invoca al siguiente eslabón (`@app.call`) y espera su retorno.
|
|
34
|
+
# Una vez recibida la respuesta, procesa el `body` antes de devolverla hacia arriba en la cadena.
|
|
35
|
+
#
|
|
36
|
+
# @param env [BugBunny::Request] El objeto request actual (el entorno).
|
|
37
|
+
# @return [Hash] La respuesta con el campo 'body' transformado (si era JSON).
|
|
38
|
+
def call(env)
|
|
39
|
+
response = @app.call(env)
|
|
40
|
+
# Parseamos el body DESPUÉS de recibir la respuesta (Post-processing)
|
|
41
|
+
response['body'] = parse_body(response['body'])
|
|
42
|
+
response
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Intenta convertir el cuerpo de la respuesta a una estructura Ruby nativa.
|
|
46
|
+
#
|
|
47
|
+
# @param body [String, Hash, Array, nil] El cuerpo original de la respuesta.
|
|
48
|
+
# @return [Object] El cuerpo parseado (Hash/Array) o el objeto original si falla el parseo.
|
|
49
|
+
# @api private
|
|
50
|
+
def parse_body(body)
|
|
51
|
+
return nil if body.nil? || body.empty?
|
|
52
|
+
|
|
53
|
+
# Si ya es un objeto (ej: tests o mocks), lo dejamos pasar, si es String intentamos parsear.
|
|
54
|
+
parsed = body.is_a?(String) ? JSON.parse(body) : body
|
|
55
|
+
|
|
56
|
+
# Rails Magic: Indifferent Access
|
|
57
|
+
# Si estamos en un entorno Rails, aplicamos la conversión para UX del desarrollador.
|
|
58
|
+
if defined?(ActiveSupport)
|
|
59
|
+
if parsed.is_a?(Array)
|
|
60
|
+
parsed.map! { |e| e.try(:with_indifferent_access) || e }
|
|
61
|
+
elsif parsed.is_a?(Hash)
|
|
62
|
+
parsed = parsed.with_indifferent_access
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
parsed
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
# Si el body no es un JSON válido (ej: texto plano o error del servidor),
|
|
69
|
+
# devolvemos el string original sin lanzar excepción.
|
|
70
|
+
body
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# lib/bug_bunny/middleware/raise_error.rb
|
|
2
|
+
module BugBunny
|
|
3
|
+
module Middleware
|
|
4
|
+
# Middleware que inspecciona el status de la respuesta y lanza excepciones
|
|
5
|
+
# si se encuentran errores (4xx o 5xx).
|
|
6
|
+
#
|
|
7
|
+
# Mapea los códigos de estado HTTP/AMQP a excepciones específicas de Ruby para facilitar
|
|
8
|
+
# el manejo de errores mediante `rescue`.
|
|
9
|
+
#
|
|
10
|
+
# @note Orden de Middlewares:
|
|
11
|
+
# Se recomienda usar este middleware **antes** de `JsonResponse` si deseas que
|
|
12
|
+
# la excepción contenga el cuerpo ya parseado (Hash).
|
|
13
|
+
#
|
|
14
|
+
# @example Configuración recomendada
|
|
15
|
+
# client = BugBunny::Client.new(pool: POOL) do |conn|
|
|
16
|
+
# conn.use BugBunny::Middleware::RaiseError # 1. Verifica errores primero (al salir)
|
|
17
|
+
# conn.use BugBunny::Middleware::JsonResponse # 2. Parsea JSON
|
|
18
|
+
# end
|
|
19
|
+
class RaiseError
|
|
20
|
+
# Inicializa el middleware.
|
|
21
|
+
# @param app [Object] El siguiente middleware o la aplicación final.
|
|
22
|
+
def initialize(app)
|
|
23
|
+
@app = app
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Ejecuta el middleware.
|
|
27
|
+
# Realiza la petición y, al retornar, verifica el estado de la respuesta.
|
|
28
|
+
#
|
|
29
|
+
# @param env [BugBunny::Request] El objeto request actual.
|
|
30
|
+
# @return [Hash] La respuesta si el status es exitoso (2xx).
|
|
31
|
+
# @raise [BugBunny::ClientError] Si el status es 4xx.
|
|
32
|
+
# @raise [BugBunny::ServerError] Si el status es 5xx.
|
|
33
|
+
def call(env)
|
|
34
|
+
response = @app.call(env)
|
|
35
|
+
on_complete(response)
|
|
36
|
+
response
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Verifica el código de estado y lanza la excepción correspondiente.
|
|
40
|
+
#
|
|
41
|
+
# Mapeo de errores:
|
|
42
|
+
# * 400 -> {BugBunny::BadRequest}
|
|
43
|
+
# * 404 -> {BugBunny::NotFound}
|
|
44
|
+
# * 406 -> {BugBunny::NotAcceptable}
|
|
45
|
+
# * 408 -> {BugBunny::RequestTimeout}
|
|
46
|
+
# * 422 -> {BugBunny::UnprocessableEntity}
|
|
47
|
+
# * 500 -> {BugBunny::InternalServerError}
|
|
48
|
+
# * Otros 4xx -> {BugBunny::ClientError}
|
|
49
|
+
#
|
|
50
|
+
# @param response [Hash] El hash de respuesta conteniendo 'status' y 'body'.
|
|
51
|
+
# @return [void]
|
|
52
|
+
def on_complete(response)
|
|
53
|
+
status = response['status'].to_i
|
|
54
|
+
body = response['body'] # Nota: Puede ser String o Hash dependiendo de JsonResponse
|
|
55
|
+
|
|
56
|
+
case status
|
|
57
|
+
when 200..299
|
|
58
|
+
# OK: No action needed
|
|
59
|
+
when 400 then raise BugBunny::BadRequest, body
|
|
60
|
+
when 404 then raise BugBunny::NotFound
|
|
61
|
+
when 406 then raise BugBunny::NotAcceptable
|
|
62
|
+
when 408 then raise BugBunny::RequestTimeout
|
|
63
|
+
when 422 then raise BugBunny::UnprocessableEntity, body
|
|
64
|
+
when 500 then raise BugBunny::InternalServerError, body
|
|
65
|
+
else
|
|
66
|
+
raise BugBunny::ClientError, "Unknown error: #{status}" if status >= 400
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# lib/bug_bunny/middleware/stack.rb
|
|
2
|
+
|
|
3
|
+
module BugBunny
|
|
4
|
+
module Middleware
|
|
5
|
+
# Gestiona una pila (stack) de middlewares para procesar peticiones y respuestas.
|
|
6
|
+
#
|
|
7
|
+
# Implementa el patrón "Builder" para construir una cadena de responsabilidades.
|
|
8
|
+
# Permite registrar clases de middleware que envolverán la ejecución final (el Producer).
|
|
9
|
+
# Es similar en funcionamiento a `Rack::Builder` o `Faraday::RackBuilder`.
|
|
10
|
+
#
|
|
11
|
+
# @example Construcción manual
|
|
12
|
+
# stack = BugBunny::Middleware::Stack.new
|
|
13
|
+
# stack.use BugBunny::Middleware::Logger
|
|
14
|
+
# stack.use BugBunny::Middleware::JsonResponse
|
|
15
|
+
#
|
|
16
|
+
# # 'app' será el Logger, que llama a JsonResponse, que llama a final_producer
|
|
17
|
+
# app = stack.build(final_producer)
|
|
18
|
+
# app.call(request)
|
|
19
|
+
class Stack
|
|
20
|
+
# Inicializa una nueva pila de middlewares vacía.
|
|
21
|
+
def initialize
|
|
22
|
+
@middlewares = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Registra un middleware en la pila.
|
|
26
|
+
#
|
|
27
|
+
# @param klass [Class] La clase del middleware. Debe tener un constructor `initialize(app, *args)` y un método `call(env)`.
|
|
28
|
+
# @param args [Array] Argumentos opcionales que se pasarán al constructor del middleware.
|
|
29
|
+
# @yield [block] Bloque opcional que se pasará al constructor del middleware.
|
|
30
|
+
# @return [Array] La lista actualizada de configuraciones de middleware.
|
|
31
|
+
def use(klass, *args, &block)
|
|
32
|
+
@middlewares << { klass: klass, args: args, block: block }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Construye la cadena de ejecución (app) componiendo todos los middlewares registrados.
|
|
36
|
+
#
|
|
37
|
+
# Itera sobre los middlewares en orden inverso, envolviendo la `final_app` capa por capa.
|
|
38
|
+
# Esto asegura que el primer middleware agregado con {#use} sea el más externo y, por tanto,
|
|
39
|
+
# el primero en recibir la llamada `call`.
|
|
40
|
+
#
|
|
41
|
+
# @param final_app [Proc, Object] El objeto final que recibirá la petición (generalmente el Producer). Debe responder a `call`.
|
|
42
|
+
# @return [Object] El primer eslabón de la cadena (el middleware más externo).
|
|
43
|
+
def build(final_app)
|
|
44
|
+
@middlewares.reverse.inject(final_app) do |app, config|
|
|
45
|
+
config[:klass].new(app, *config[:args], &config[:block])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require 'concurrent'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module BugBunny
|
|
6
|
+
# Clase de bajo nivel encargada de la publicación de mensajes en RabbitMQ.
|
|
7
|
+
#
|
|
8
|
+
# Actúa como el "motor" de envío del framework. Es responsable de:
|
|
9
|
+
# 1. Serializar el payload del mensaje.
|
|
10
|
+
# 2. Manejar la publicación asíncrona (Fire-and-Forget).
|
|
11
|
+
# 3. Implementar el patrón RPC síncrono utilizando futuros (`Concurrent::IVar`).
|
|
12
|
+
# 4. Gestionar la escucha de respuestas en la cola especial de RabbitMQ.
|
|
13
|
+
class Producer
|
|
14
|
+
# Inicializa el productor.
|
|
15
|
+
#
|
|
16
|
+
# Prepara las estructuras de concurrencia necesarias para manejar múltiples
|
|
17
|
+
# peticiones RPC simultáneas sobre la misma conexión.
|
|
18
|
+
#
|
|
19
|
+
# @param session [BugBunny::Session] Sesión activa de Bunny (wrapper).
|
|
20
|
+
def initialize(session)
|
|
21
|
+
@session = session
|
|
22
|
+
# Mapa thread-safe para correlacionar IDs de petición con sus futuros (IVars)
|
|
23
|
+
@pending_requests = Concurrent::Map.new
|
|
24
|
+
@reply_listener_mutex = Mutex.new
|
|
25
|
+
@reply_listener_started = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Envía un mensaje de forma asíncrona (Fire-and-Forget).
|
|
29
|
+
#
|
|
30
|
+
# Serializa el cuerpo del request, resuelve el exchange y publica el mensaje
|
|
31
|
+
# sin esperar confirmación ni respuesta del consumidor.
|
|
32
|
+
#
|
|
33
|
+
# @param request [BugBunny::Request] Objeto con la configuración del envío (body, routing_key, etc).
|
|
34
|
+
# @return [void]
|
|
35
|
+
def fire(request)
|
|
36
|
+
x = @session.exchange(name: request.exchange, type: request.exchange_type)
|
|
37
|
+
payload = serialize_message(request.body)
|
|
38
|
+
opts = request.amqp_options
|
|
39
|
+
|
|
40
|
+
BugBunny.configuration.logger.info("[BugBunny] Publishing to #{request.exchange}/#{request.final_routing_key}")
|
|
41
|
+
|
|
42
|
+
x.publish(payload, opts.merge(routing_key: request.final_routing_key))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
|
|
46
|
+
#
|
|
47
|
+
# Implementa el mecanismo "Direct Reply-to" de RabbitMQ (`amq.rabbitmq.reply-to`)
|
|
48
|
+
# para recibir la respuesta directamente sin necesidad de crear colas temporales
|
|
49
|
+
# por cada petición, lo cual mejora significativamente el rendimiento.
|
|
50
|
+
#
|
|
51
|
+
# El flujo es:
|
|
52
|
+
# 1. Asegura que hay un consumidor escuchando en `amq.rabbitmq.reply-to`.
|
|
53
|
+
# 2. Genera un `correlation_id` único.
|
|
54
|
+
# 3. Crea una promesa (`Concurrent::IVar`) y la registra.
|
|
55
|
+
# 4. Publica el mensaje y bloquea esperando que la promesa se resuelva.
|
|
56
|
+
#
|
|
57
|
+
# @param request [BugBunny::Request] Objeto request configurado.
|
|
58
|
+
# @return [Hash] El cuerpo de la respuesta parseado desde JSON.
|
|
59
|
+
# @raise [BugBunny::RequestTimeout] Si el servidor no responde dentro del tiempo límite.
|
|
60
|
+
# @raise [BugBunny::InternalServerError] Si la respuesta no es un JSON válido.
|
|
61
|
+
def rpc(request)
|
|
62
|
+
ensure_reply_listener!
|
|
63
|
+
|
|
64
|
+
request.correlation_id ||= SecureRandom.uuid
|
|
65
|
+
request.reply_to = 'amq.rabbitmq.reply-to'
|
|
66
|
+
wait_timeout = request.timeout || BugBunny.configuration.rpc_timeout
|
|
67
|
+
|
|
68
|
+
# Creamos un futuro (IVar) que actuará como semáforo
|
|
69
|
+
future = Concurrent::IVar.new
|
|
70
|
+
@pending_requests[request.correlation_id] = future
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
fire(request)
|
|
74
|
+
|
|
75
|
+
# Bloqueamos el hilo aquí hasta que llegue la respuesta o expire el timeout
|
|
76
|
+
response_payload = future.value(wait_timeout)
|
|
77
|
+
|
|
78
|
+
if response_payload.nil?
|
|
79
|
+
raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.action}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
parse_response(response_payload)
|
|
83
|
+
ensure
|
|
84
|
+
# Limpieza vital para evitar fugas de memoria en el mapa
|
|
85
|
+
@pending_requests.delete(request.correlation_id)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Serializa el mensaje para su transporte.
|
|
92
|
+
# @param msg [Hash, String, Object] El mensaje a serializar.
|
|
93
|
+
# @return [String] Cadena JSON o string crudo.
|
|
94
|
+
def serialize_message(msg)
|
|
95
|
+
msg.is_a?(Hash) ? msg.to_json : msg.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Intenta parsear la respuesta recibida.
|
|
99
|
+
# @raise [BugBunny::InternalServerError] Si el payload no es JSON válido.
|
|
100
|
+
def parse_response(payload)
|
|
101
|
+
JSON.parse(payload)
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
raise BugBunny::InternalServerError, "Invalid JSON response"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Inicia el consumidor de respuestas RPC de forma perezosa (Lazy Initialization).
|
|
107
|
+
#
|
|
108
|
+
# Utiliza un patrón de "Double-Checked Locking" con Mutex para asegurar que
|
|
109
|
+
# solo se crea un listener por instancia de Producer, incluso en entornos multi-hilo.
|
|
110
|
+
#
|
|
111
|
+
# Escucha en la pseudo-cola `amq.rabbitmq.reply-to`. Cuando llega un mensaje,
|
|
112
|
+
# busca el `correlation_id` en el mapa de pendientes y completa el futuro (`IVar`),
|
|
113
|
+
# desbloqueando así al hilo que llamó a {#rpc}.
|
|
114
|
+
def ensure_reply_listener!
|
|
115
|
+
return if @reply_listener_started
|
|
116
|
+
|
|
117
|
+
@reply_listener_mutex.synchronize do
|
|
118
|
+
return if @reply_listener_started
|
|
119
|
+
|
|
120
|
+
# Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
|
|
121
|
+
@session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
|
|
122
|
+
if (future = @pending_requests[props.correlation_id])
|
|
123
|
+
future.set(body)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
@reply_listener_started = true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|