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.
@@ -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