bug_bunny 4.8.0 → 4.8.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.
- checksums.yaml +4 -4
- data/.agents/skills/documentation-writer/SKILL.md +45 -0
- data/.agents/skills/gem-release/SKILL.md +114 -0
- data/.agents/skills/quality-code/SKILL.md +51 -0
- data/.agents/skills/sentry/SKILL.md +135 -0
- data/.agents/skills/sentry/references/api-endpoints.md +147 -0
- data/.agents/skills/sentry/scripts/sentry.rb +194 -0
- data/.agents/skills/skill-builder/SKILL.md +232 -0
- data/.agents/skills/skill-manager/SKILL.md +172 -0
- data/.agents/skills/skill-manager/scripts/sync.rb +310 -0
- data/.agents/skills/yard/SKILL.md +311 -0
- data/.agents/skills/yard/references/tipos.md +144 -0
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +28 -231
- data/lib/bug_bunny/version.rb +1 -1
- data/skill/SKILL.md +230 -0
- data/skill/references/client-middleware.md +144 -0
- data/skill/references/consumer.md +104 -0
- data/skill/references/controller.md +105 -0
- data/skill/references/errores.md +97 -0
- data/skill/references/resource.md +116 -0
- data/skill/references/routing.md +82 -0
- data/skill/references/testing.md +138 -0
- data/skills.lock +24 -0
- data/skills.yml +19 -0
- metadata +24 -28
- data/.claude/commands/gem-ai-setup.md +0 -174
- data/.claude/commands/pr.md +0 -53
- data/.claude/commands/release.md +0 -52
- data/.claude/commands/rubocop.md +0 -22
- data/.claude/commands/service-ai-setup.md +0 -168
- data/.claude/commands/test.md +0 -28
- data/.claude/commands/yard.md +0 -46
- data/docs/_index.md +0 -50
- data/docs/ai/_index.md +0 -56
- data/docs/ai/antipatterns.md +0 -166
- data/docs/ai/api.md +0 -251
- data/docs/ai/architecture.md +0 -92
- data/docs/ai/errors.md +0 -158
- data/docs/ai/faq_external.md +0 -133
- data/docs/ai/faq_internal.md +0 -86
- data/docs/ai/glossary.md +0 -45
- data/docs/concepts.md +0 -140
- data/docs/howto/controller.md +0 -194
- data/docs/howto/middleware_client.md +0 -119
- data/docs/howto/middleware_consumer.md +0 -127
- data/docs/howto/rails.md +0 -214
- data/docs/howto/resource.md +0 -200
- data/docs/howto/routing.md +0 -133
- data/docs/howto/testing.md +0 -259
- data/docs/howto/tracing.md +0 -119
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Client y Middleware
|
|
2
|
+
|
|
3
|
+
## Client
|
|
4
|
+
|
|
5
|
+
API de alto nivel para publicar mensajes. Usa un pool de conexiones y una pila de middlewares.
|
|
6
|
+
|
|
7
|
+
### Métodos Principales
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# RPC síncrono — bloquea hasta respuesta
|
|
11
|
+
response = client.request('users/123', method: :get, timeout: 30)
|
|
12
|
+
response = client.request('users', method: :post, body: { name: 'John' })
|
|
13
|
+
# → { 'status' => 200, 'body' => {...} }
|
|
14
|
+
|
|
15
|
+
# Fire-and-Forget — no bloquea
|
|
16
|
+
client.publish('events/user_created', method: :post, body: { user_id: 42 })
|
|
17
|
+
# → { 'status' => 202, 'body' => nil }
|
|
18
|
+
|
|
19
|
+
# General con bloque
|
|
20
|
+
client.send(url) { |req| req.delivery_mode = :publish }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Argumentos de Request
|
|
24
|
+
|
|
25
|
+
| Argumento | Tipo | Default | Propósito |
|
|
26
|
+
|-----------|------|---------|-----------|
|
|
27
|
+
| `method` | Symbol | `:get` | Verbo HTTP |
|
|
28
|
+
| `body` | Hash/String | nil | Payload del mensaje |
|
|
29
|
+
| `headers` | Hash | {} | Headers AMQP custom |
|
|
30
|
+
| `params` | Hash | {} | Query string params |
|
|
31
|
+
| `timeout` | Integer | config | Override de RPC timeout |
|
|
32
|
+
| `exchange` | String | config | Exchange destino |
|
|
33
|
+
| `exchange_type` | String | 'direct' | Tipo de exchange |
|
|
34
|
+
| `routing_key` | String | path | Override de routing key |
|
|
35
|
+
| `exchange_options` | Hash | {} | Cascade level 3 |
|
|
36
|
+
| `queue_options` | Hash | {} | Cascade level 3 |
|
|
37
|
+
|
|
38
|
+
## Producer (bajo nivel)
|
|
39
|
+
|
|
40
|
+
El `Producer` es usado internamente por el `Client`. Implementa los dos patrones de entrega.
|
|
41
|
+
|
|
42
|
+
### RPC
|
|
43
|
+
|
|
44
|
+
- Usa `amq.rabbitmq.reply-to` (direct reply-to pattern).
|
|
45
|
+
- Tracking de `correlation_id` en `Concurrent::Map` (thread-safe).
|
|
46
|
+
- Reply listener (`basic_consume`) auto-iniciado en el primer RPC.
|
|
47
|
+
- Double-checked locking mutex para seguridad del listener.
|
|
48
|
+
- Timeout lanza `BugBunny::RequestTimeout`.
|
|
49
|
+
|
|
50
|
+
### Fire-and-Forget
|
|
51
|
+
|
|
52
|
+
- Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
|
|
53
|
+
- Sin confirmación de procesamiento.
|
|
54
|
+
|
|
55
|
+
## Middleware Stack (Client-side, Onion Architecture)
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Request ─→ RaiseError ─→ JsonResponse ─→ Custom ─→ Producer
|
|
59
|
+
Response ←─ RaiseError ←─ JsonResponse ←─ Custom ←─
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Registrar Middlewares
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class Order < BugBunny::Resource
|
|
66
|
+
client_middleware do |stack|
|
|
67
|
+
stack.use BugBunny::Middleware::RaiseError
|
|
68
|
+
stack.use BugBunny::Middleware::JsonResponse
|
|
69
|
+
stack.use MyCustomMiddleware
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Crear un Middleware Custom
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class MyMiddleware < BugBunny::Middleware::Base
|
|
78
|
+
def on_request(env)
|
|
79
|
+
# Modificar request antes de enviar
|
|
80
|
+
env.headers['X-Custom'] = 'value'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def on_complete(response)
|
|
84
|
+
# Modificar response después de recibir
|
|
85
|
+
response['body'] = transform(response['body'])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
El método `call` del `Base` invoca `on_request`, delega a `@app.call`, y luego `on_complete`.
|
|
91
|
+
|
|
92
|
+
### Middlewares Incluidos
|
|
93
|
+
|
|
94
|
+
**RaiseError** — Mapea status HTTP a excepciones:
|
|
95
|
+
|
|
96
|
+
| Status | Excepción |
|
|
97
|
+
|--------|-----------|
|
|
98
|
+
| 200-299 | (ninguna) |
|
|
99
|
+
| 400 | `BadRequest` |
|
|
100
|
+
| 404 | `NotFound` |
|
|
101
|
+
| 406 | `NotAcceptable` |
|
|
102
|
+
| 408 | `RequestTimeout` |
|
|
103
|
+
| 409 | `Conflict` |
|
|
104
|
+
| 422 | `UnprocessableEntity` (con smart extraction de errors) |
|
|
105
|
+
| 500+ | `InternalServerError` / `ServerError` |
|
|
106
|
+
| 4xx otros | `ClientError` |
|
|
107
|
+
|
|
108
|
+
**JsonResponse** — Auto-parsea `response['body']` de String a Hash/Array. Aplica `HashWithIndifferentAccess` si disponible.
|
|
109
|
+
|
|
110
|
+
## Request Object
|
|
111
|
+
|
|
112
|
+
Value object con toda la metadata AMQP:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
req.path # 'users/123'
|
|
116
|
+
req.method # :get
|
|
117
|
+
req.body # Hash, Array, String o nil
|
|
118
|
+
req.headers # Hash custom
|
|
119
|
+
req.params # Hash query string
|
|
120
|
+
req.full_path # path + query string
|
|
121
|
+
req.delivery_mode # :rpc o :publish
|
|
122
|
+
req.exchange # String destino
|
|
123
|
+
req.exchange_type # 'direct', 'topic', 'fanout'
|
|
124
|
+
req.correlation_id # UUID auto-generado
|
|
125
|
+
req.reply_to # 'amq.rabbitmq.reply-to' (auto para RPC)
|
|
126
|
+
req.timestamp # Time.now.to_i
|
|
127
|
+
req.content_type # 'application/json'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Cascada de Configuración (3 niveles)
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
# Level 1: Gem defaults
|
|
134
|
+
{ durable: false, auto_delete: false } # exchanges
|
|
135
|
+
{ exclusive: false, durable: false, auto_delete: true } # queues
|
|
136
|
+
|
|
137
|
+
# Level 2: Global config
|
|
138
|
+
BugBunny.configure { |c| c.exchange_options = { durable: true } }
|
|
139
|
+
|
|
140
|
+
# Level 3: Per-request
|
|
141
|
+
client.request('users', exchange_options: { durable: true })
|
|
142
|
+
|
|
143
|
+
# Merge final: Level1.merge(Level2).merge(Level3)
|
|
144
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Consumer
|
|
2
|
+
|
|
3
|
+
## Subscribe
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
consumer = BugBunny::Consumer.subscribe(
|
|
7
|
+
connection: bunny_session,
|
|
8
|
+
queue_name: 'my_app_queue',
|
|
9
|
+
exchange_name: 'my_exchange',
|
|
10
|
+
routing_key: 'users.*',
|
|
11
|
+
exchange_type: 'topic',
|
|
12
|
+
exchange_opts: { durable: true },
|
|
13
|
+
queue_opts: { auto_delete: false },
|
|
14
|
+
block: true # Si false, retorna inmediatamente
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Flujo de Procesamiento
|
|
19
|
+
|
|
20
|
+
1. Escucha en la queue con `manual_ack: true`.
|
|
21
|
+
2. Valida que el mensaje tenga header `type` (path).
|
|
22
|
+
3. Parsea el método HTTP de headers (`x-http-method` o `method`).
|
|
23
|
+
4. Reconoce la ruta con `BugBunny.routes.recognize(method, path)`.
|
|
24
|
+
5. Resuelve el controlador validando herencia de `BugBunny::Controller`.
|
|
25
|
+
6. Ejecuta consumer middlewares → controller callbacks → acción.
|
|
26
|
+
7. Responde via `reply_to` si está presente (RPC).
|
|
27
|
+
8. Hace `ack` del mensaje. En caso de error, `reject`.
|
|
28
|
+
|
|
29
|
+
## Lifecycle
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
consumer.shutdown # Cierra canal, detiene health check
|
|
33
|
+
consumer.session # Accede al Session subyacente
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Consumer Middleware
|
|
37
|
+
|
|
38
|
+
### Registrar
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
BugBunny.configuration.consumer_middlewares.use MyTracing::Middleware
|
|
42
|
+
BugBunny.configuration.consumer_middlewares.use MyAuth::Middleware
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Crear Middleware
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class MyMiddleware < BugBunny::ConsumerMiddleware::Base
|
|
49
|
+
def call(delivery_info, properties, body)
|
|
50
|
+
# Pre-procesamiento (ej: hidratar trace context)
|
|
51
|
+
@app.call(delivery_info, properties, body)
|
|
52
|
+
# Post-procesamiento (ej: cleanup)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Comportamiento del Stack
|
|
58
|
+
|
|
59
|
+
- El stack toma un **snapshot** al inicio de `call()`.
|
|
60
|
+
- Registros concurrentes durante la ejecución NO afectan la cadena actual.
|
|
61
|
+
- Thread-safe para registros con `use()`.
|
|
62
|
+
- Orden FIFO: el primero registrado es el primero en ejecutar.
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
stack.use(A) # A.call → B.call → core
|
|
66
|
+
stack.use(B)
|
|
67
|
+
stack.empty? # false
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Health Check
|
|
71
|
+
|
|
72
|
+
- **Intervalo:** Configurable (default 60s).
|
|
73
|
+
- **Verificación:** `queue.declare(passive: true)` para confirmar conexión.
|
|
74
|
+
- **Touchfile:** Si `config.health_check_file` está configurado, actualiza mtime.
|
|
75
|
+
- **Fallo:** Cierra canal, dispara loop de reconexión.
|
|
76
|
+
|
|
77
|
+
### Kubernetes Integration
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
livenessProbe:
|
|
81
|
+
exec:
|
|
82
|
+
command:
|
|
83
|
+
- test
|
|
84
|
+
- -f
|
|
85
|
+
- /app/tmp/bb_health
|
|
86
|
+
initialDelaySeconds: 30
|
|
87
|
+
periodSeconds: 60
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Reconexión
|
|
91
|
+
|
|
92
|
+
- Exponential backoff desde `network_recovery_interval` hasta `max_reconnect_interval`.
|
|
93
|
+
- Intentos limitados por `max_reconnect_attempts` (nil = infinito).
|
|
94
|
+
- Logs estructurados en cada intento: `event=session.reconnect_attempt`.
|
|
95
|
+
- Si se agotan intentos: `event=consumer.reconnect_exhausted`, lanza `CommunicationError`.
|
|
96
|
+
|
|
97
|
+
## Manejo de Errores
|
|
98
|
+
|
|
99
|
+
| Situación | Respuesta |
|
|
100
|
+
|-----------|-----------|
|
|
101
|
+
| Ruta no encontrada | 404 + log `event=consumer.route_not_found` |
|
|
102
|
+
| Controller no encontrado (namespace) | 404 + log `event=consumer.controller_not_found` |
|
|
103
|
+
| Controller no hereda de BugBunny::Controller | `SecurityError` |
|
|
104
|
+
| Excepción no capturada en controller | 500 + log `event=controller.unhandled_exception` con backtrace |
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Controllers
|
|
2
|
+
|
|
3
|
+
## Estructura Base
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class UsersController < BugBunny::Controller
|
|
7
|
+
before_action :authenticate, only: [:create, :update, :destroy]
|
|
8
|
+
around_action :with_tracing
|
|
9
|
+
after_action :log_response, only: [:index, :show]
|
|
10
|
+
rescue_from BugBunny::NotFound, with: :render_not_found
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
users = UserService.list(params[:filter])
|
|
14
|
+
render status: :ok, json: { users: users }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
user = UserService.find(params[:id])
|
|
19
|
+
render status: :ok, json: user
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def authenticate
|
|
25
|
+
render status: :forbidden, json: { error: 'Unauthorized' } unless valid_token?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_tracing
|
|
29
|
+
Tracer.start
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
Tracer.finish
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log_response
|
|
36
|
+
logger.info("Response: #{rendered_response[:status]}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render_not_found(exception)
|
|
40
|
+
render status: :not_found, json: { error: exception.message }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Callbacks — Orden de Ejecución
|
|
46
|
+
|
|
47
|
+
1. `around_action` blocks (capa externa, envuelve todo con `yield`)
|
|
48
|
+
2. `before_action` callbacks (se detiene si se llama `render`)
|
|
49
|
+
3. Acción del controlador
|
|
50
|
+
4. `after_action` callbacks (NO se ejecuta si before_action haltó o si hubo excepción)
|
|
51
|
+
|
|
52
|
+
## Filtros: only / except
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
before_action :authenticate, only: [:create, :update]
|
|
56
|
+
after_action :audit, except: [:index]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## rescue_from
|
|
60
|
+
|
|
61
|
+
Captura excepciones con handler method o bloque:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
rescue_from BugBunny::UnprocessableEntity, with: :handle_validation
|
|
65
|
+
rescue_from StandardError do |e|
|
|
66
|
+
render status: :internal_server_error, json: { error: e.message }
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Atributos Disponibles en el Controller
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
@headers # Hash — metadata del mensaje AMQP (method, routing_key, id, etc.)
|
|
74
|
+
@params # HashWithIndifferentAccess — body JSON + query params unificados
|
|
75
|
+
@raw_string # String — body crudo si no es JSON
|
|
76
|
+
@response_headers # Hash — headers para el reply RPC
|
|
77
|
+
@rendered_response # Hash o nil — respuesta renderizada
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Render
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
render(status: :ok, json: { users: [...] })
|
|
84
|
+
render(status: 201, json: @user)
|
|
85
|
+
render(status: :unprocessable_entity, json: { errors: @resource.errors }, headers: { 'X-Custom' => 'val' })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Si no se llama `render`, el response default es `{ status: 204, body: nil }`.
|
|
89
|
+
|
|
90
|
+
## Log Tags
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# Global
|
|
94
|
+
BugBunny.configuration.log_tags = [:uuid, :user_id, ->(c) { c.current_user }]
|
|
95
|
+
|
|
96
|
+
# Por controller
|
|
97
|
+
class UsersController < BugBunny::Controller
|
|
98
|
+
self.log_tags = [:uuid]
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Tipos soportados:
|
|
103
|
+
- **Symbol:** Llama al método del controller (ej: `:uuid` → `self.uuid`)
|
|
104
|
+
- **Proc:** Ejecuta con el controller como argumento
|
|
105
|
+
- **String:** Valor literal
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Catálogo de Errores
|
|
2
|
+
|
|
3
|
+
## Jerarquía
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
StandardError
|
|
7
|
+
└── BugBunny::Error
|
|
8
|
+
├── BugBunny::CommunicationError
|
|
9
|
+
├── BugBunny::ConfigurationError
|
|
10
|
+
├── BugBunny::SecurityError
|
|
11
|
+
├── BugBunny::ClientError (4xx)
|
|
12
|
+
│ ├── BugBunny::BadRequest (400)
|
|
13
|
+
│ ├── BugBunny::NotFound (404)
|
|
14
|
+
│ ├── BugBunny::NotAcceptable (406)
|
|
15
|
+
│ ├── BugBunny::RequestTimeout (408)
|
|
16
|
+
│ ├── BugBunny::Conflict (409)
|
|
17
|
+
│ └── BugBunny::UnprocessableEntity (422)
|
|
18
|
+
└── BugBunny::ServerError (5xx)
|
|
19
|
+
└── BugBunny::InternalServerError (500+)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Errores de Infraestructura
|
|
23
|
+
|
|
24
|
+
### BugBunny::CommunicationError
|
|
25
|
+
**Causa:** Fallo de conexión TCP/AMQP o reconexión agotada (`max_reconnect_attempts`).
|
|
26
|
+
**Cuándo:** Al intentar publicar o consumir sin conexión activa, o tras agotar intentos de reconexión.
|
|
27
|
+
**Resolución:** Verificar conectividad a RabbitMQ (host, port, firewall). Revisar logs `event=session.reconnect_failed`. Ajustar `max_reconnect_attempts` y `max_reconnect_interval`.
|
|
28
|
+
|
|
29
|
+
### BugBunny::ConfigurationError
|
|
30
|
+
**Causa:** Campo requerido faltante o valor fuera de rango en `BugBunny.configure`.
|
|
31
|
+
**Validaciones:** host (String no vacío), port (1-65535), username/password (no nil), heartbeat (0-3600), rpc_timeout (>0), channel_prefetch (1-10000).
|
|
32
|
+
**Resolución:** Revisar el bloque `BugBunny.configure` y corregir valores.
|
|
33
|
+
|
|
34
|
+
### BugBunny::SecurityError
|
|
35
|
+
**Causa:** Un mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
36
|
+
**Cuándo:** El consumer resuelve la clase pero falla la validación `is_a?(BugBunny::Controller)`.
|
|
37
|
+
**Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
|
|
38
|
+
|
|
39
|
+
## Errores de Cliente (4xx)
|
|
40
|
+
|
|
41
|
+
### BugBunny::BadRequest (400)
|
|
42
|
+
**Causa:** Request malformado o sintaxis inválida.
|
|
43
|
+
**Resolución:** Verificar formato del body y headers.
|
|
44
|
+
|
|
45
|
+
### BugBunny::NotFound (404)
|
|
46
|
+
**Causa:** El recurso solicitado no existe en el servicio remoto.
|
|
47
|
+
**Resolución:** Verificar ID del recurso y que el endpoint exista.
|
|
48
|
+
|
|
49
|
+
### BugBunny::NotAcceptable (406)
|
|
50
|
+
**Causa:** Negociación de contenido falló.
|
|
51
|
+
**Resolución:** Verificar `content_type` del request.
|
|
52
|
+
|
|
53
|
+
### BugBunny::RequestTimeout (408)
|
|
54
|
+
**Causa:** No hubo respuesta en `config.rpc_timeout` segundos.
|
|
55
|
+
**Cuándo:** El `Concurrent::IVar` expira sin recibir reply.
|
|
56
|
+
**Resolución:** Verificar que el worker esté activo. Revisar saturación de prefetch. Aumentar `rpc_timeout` si el procesamiento es legítimamente lento.
|
|
57
|
+
|
|
58
|
+
### BugBunny::Conflict (409)
|
|
59
|
+
**Causa:** Conflicto de regla de negocio (ej: recurso ya existe, versión desactualizada).
|
|
60
|
+
**Resolución:** Reintentar tras refrescar el estado del recurso.
|
|
61
|
+
|
|
62
|
+
### BugBunny::UnprocessableEntity (422)
|
|
63
|
+
**Causa:** Fallo de validación en el servicio remoto.
|
|
64
|
+
**Acceso a errores:**
|
|
65
|
+
```ruby
|
|
66
|
+
begin
|
|
67
|
+
order.save
|
|
68
|
+
rescue BugBunny::UnprocessableEntity => e
|
|
69
|
+
e.error_messages # Hash, Array o String con detalles
|
|
70
|
+
e.raw_response # Response original completo
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
**Smart extraction:** Busca `errors`, `error`, `detail`, `message` en el body. Formatea como Hash descriptivo si no encuentra convención.
|
|
74
|
+
**En Resource:** `save` captura 422 automáticamente, carga `resource.errors` y retorna `false`.
|
|
75
|
+
|
|
76
|
+
## Errores de Servidor (5xx)
|
|
77
|
+
|
|
78
|
+
### BugBunny::InternalServerError (500+)
|
|
79
|
+
**Causa:** Error no controlado en el servicio remoto.
|
|
80
|
+
**Resolución:** Revisar logs del servicio remoto. Verificar backtrace en `event=controller.unhandled_exception`.
|
|
81
|
+
|
|
82
|
+
### BugBunny::ServerError (base 5xx)
|
|
83
|
+
**Causa:** Cualquier error de servidor no mapeado a InternalServerError.
|
|
84
|
+
**Resolución:** Similar a InternalServerError.
|
|
85
|
+
|
|
86
|
+
## Formato de Mensajes de Error
|
|
87
|
+
|
|
88
|
+
El middleware `RaiseError` construye el mensaje así:
|
|
89
|
+
1. Busca `{ "error": "...", "detail": "..." }` en el body.
|
|
90
|
+
2. Si no encuentra, usa el Hash completo como JSON.
|
|
91
|
+
3. Si el body está vacío, usa `"Unknown Error"`.
|
|
92
|
+
|
|
93
|
+
## Connection Pool Missing
|
|
94
|
+
|
|
95
|
+
**No es una excepción BugBunny**, pero es un error común:
|
|
96
|
+
**Causa:** Se intentó usar un Resource sin asignar el pool global.
|
|
97
|
+
**Resolución:** Asegurar que `BugBunny::Resource.connection_pool = MY_POOL` se ejecute en el arranque.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Resources
|
|
2
|
+
|
|
3
|
+
## Definición
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class Order < BugBunny::Resource
|
|
7
|
+
# Infraestructura AMQP
|
|
8
|
+
@connection_pool = MY_POOL
|
|
9
|
+
@exchange = 'orders_ex'
|
|
10
|
+
@exchange_type = 'topic'
|
|
11
|
+
@resource_name = 'orders' # path en la URL
|
|
12
|
+
@routing_key = 'orders.#'
|
|
13
|
+
@param_key = 'order' # wrapper key en payloads
|
|
14
|
+
@exchange_options = { durable: true }
|
|
15
|
+
@queue_options = { auto_delete: false }
|
|
16
|
+
|
|
17
|
+
# Atributos tipados (ActiveModel::Attributes)
|
|
18
|
+
attribute :id, :integer
|
|
19
|
+
attribute :status, :string
|
|
20
|
+
attribute :total, :decimal
|
|
21
|
+
attribute :active, :boolean
|
|
22
|
+
|
|
23
|
+
# Validaciones (ActiveModel::Validations)
|
|
24
|
+
validates :status, presence: true
|
|
25
|
+
|
|
26
|
+
# Callbacks
|
|
27
|
+
before_save :normalize_status
|
|
28
|
+
after_create :notify_warehouse
|
|
29
|
+
around_destroy :audit_deletion
|
|
30
|
+
|
|
31
|
+
# Middleware client-side
|
|
32
|
+
client_middleware do |stack|
|
|
33
|
+
stack.use BugBunny::Middleware::RaiseError
|
|
34
|
+
stack.use BugBunny::Middleware::JsonResponse
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Operaciones CRUD
|
|
40
|
+
|
|
41
|
+
### Class Methods
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Order.find(42) # GET orders/42 → Order
|
|
45
|
+
Order.where(status: 'active') # GET orders?status=active → [Order, ...]
|
|
46
|
+
Order.all # GET orders → [Order, ...]
|
|
47
|
+
Order.create(status: 'pending', total: 100) # POST orders → Order
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Instance Methods
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
order = Order.new(status: 'pending')
|
|
54
|
+
order.save # POST orders (nuevo) o PUT orders/42 (existente)
|
|
55
|
+
order.update(status: 'shipped') # assign + save
|
|
56
|
+
order.destroy # DELETE orders/42
|
|
57
|
+
order.persisted? # true si fue guardado
|
|
58
|
+
order.changed? # true si tiene cambios sin guardar
|
|
59
|
+
order.errors # ActiveModel::Errors
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Save: Create vs Update
|
|
63
|
+
|
|
64
|
+
- **Nuevo** (`persisted? == false`): Envía POST con todos los atributos.
|
|
65
|
+
- **Existente** (`persisted? == true`): Envía PUT solo con atributos cambiados (`changes_to_send`).
|
|
66
|
+
- Captura `BugBunny::UnprocessableEntity` (422) y carga `resource.errors`. Retorna `false`.
|
|
67
|
+
|
|
68
|
+
## Contexto Dinámico (.with)
|
|
69
|
+
|
|
70
|
+
### Forma de bloque (recomendada)
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
Order.with(exchange: 'priority_ex', routing_key: 'priority.orders') do
|
|
74
|
+
Order.all # Usa config temporal
|
|
75
|
+
Order.find(1) # También usa config temporal
|
|
76
|
+
end
|
|
77
|
+
# Config restaurada automáticamente
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Forma de cadena (single use)
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
order = Order.with(pool: special_pool).find(42)
|
|
84
|
+
# Siguiente llamada requiere nuevo .with()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Antipatrón:** No guardar el proxy en variable para múltiples llamadas → lanza error.
|
|
88
|
+
|
|
89
|
+
## Change Tracking
|
|
90
|
+
|
|
91
|
+
Combina `ActiveModel::Dirty` con atributos dinámicos:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
order = Order.find(42)
|
|
95
|
+
order.name = 'New Name' # Atributo definido
|
|
96
|
+
order.custom_field = 'value' # Atributo dinámico
|
|
97
|
+
order.changed # → ['name', 'custom_field']
|
|
98
|
+
order.changes_to_send # → { 'name' => 'New Name', 'custom_field' => 'value' }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Callbacks Disponibles
|
|
102
|
+
|
|
103
|
+
Definidos con `define_model_callbacks`:
|
|
104
|
+
- `:save` — before/after/around save (create o update)
|
|
105
|
+
- `:create` — before/after/around create (recurso nuevo)
|
|
106
|
+
- `:update` — before/after/around update (recurso existente)
|
|
107
|
+
- `:destroy` — before/after/around destroy
|
|
108
|
+
|
|
109
|
+
## Coerción de Tipos
|
|
110
|
+
|
|
111
|
+
Los atributos tipados usan `ActiveModel::Attributes`:
|
|
112
|
+
- `'25.50'` → `BigDecimal` (con `:decimal`)
|
|
113
|
+
- `'1'` / `'true'` → `true` (con `:boolean`)
|
|
114
|
+
- `'2026-04-01T...'` → `Time` (con `:time`)
|
|
115
|
+
|
|
116
|
+
Los atributos dinámicos (no declarados) se almacenan sin coerción.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
## DSL Completo
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
BugBunny.routes.draw do
|
|
7
|
+
# Verbos HTTP individuales
|
|
8
|
+
get 'health', to: 'health#check'
|
|
9
|
+
post 'events', to: 'events#create'
|
|
10
|
+
put 'settings', to: 'settings#update'
|
|
11
|
+
patch 'settings', to: 'settings#patch'
|
|
12
|
+
delete 'cache', to: 'cache#clear'
|
|
13
|
+
|
|
14
|
+
# Resources genera 5 rutas REST
|
|
15
|
+
resources :users
|
|
16
|
+
# GET users → UsersController#index
|
|
17
|
+
# GET users/:id → UsersController#show
|
|
18
|
+
# POST users → UsersController#create
|
|
19
|
+
# PUT users/:id → UsersController#update
|
|
20
|
+
# DELETE users/:id → UsersController#destroy
|
|
21
|
+
|
|
22
|
+
# Filtros
|
|
23
|
+
resources :orders, only: [:index, :show]
|
|
24
|
+
resources :products, except: [:destroy]
|
|
25
|
+
|
|
26
|
+
# Namespaces (anidables)
|
|
27
|
+
namespace :admin do
|
|
28
|
+
namespace :v2 do
|
|
29
|
+
resources :reports # → Admin::V2::ReportsController
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Member y Collection
|
|
34
|
+
resources :nodes do
|
|
35
|
+
member do
|
|
36
|
+
put :drain # PUT nodes/:id/drain → NodesController#drain
|
|
37
|
+
post :restart # POST nodes/:id/restart → NodesController#restart
|
|
38
|
+
end
|
|
39
|
+
collection do
|
|
40
|
+
get :stats # GET nodes/stats → NodesController#stats
|
|
41
|
+
get :health # GET nodes/health → NodesController#health
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Route Matching
|
|
48
|
+
|
|
49
|
+
El `RouteSet#recognize(method, path)` busca la primera ruta que matchee:
|
|
50
|
+
|
|
51
|
+
- **Normalización:** Strips leading/trailing slashes del path.
|
|
52
|
+
- **Parámetros:** `:id` se compila a regex `(?<id>[^/]+)`. Los valores se extraen como Hash.
|
|
53
|
+
- **No match:** Retorna `nil` → el consumer responde 404.
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
route = BugBunny.routes.recognize('GET', 'users/42')
|
|
57
|
+
route.controller # => "users"
|
|
58
|
+
route.action # => "show"
|
|
59
|
+
route.namespace # => nil
|
|
60
|
+
route.params # => { 'id' => '42' }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Resolución de Controller
|
|
64
|
+
|
|
65
|
+
El consumer resuelve el controlador concatenando:
|
|
66
|
+
1. `config.controller_namespace` (default: `BugBunny::Controllers`)
|
|
67
|
+
2. `route.namespace` (si existe)
|
|
68
|
+
3. `route.controller.classify + "Controller"`
|
|
69
|
+
|
|
70
|
+
Ejemplo: namespace `:admin`, controller `:reports` → `BugBunny::Controllers::Admin::ReportsController`
|
|
71
|
+
|
|
72
|
+
Valida que el controlador sea subclase de `BugBunny::Controller`. Si no, lanza `SecurityError`.
|
|
73
|
+
|
|
74
|
+
## Route Object
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
route.http_method # String: "GET", "POST", etc.
|
|
78
|
+
route.path_pattern # String: "users/:id"
|
|
79
|
+
route.controller # String: "users"
|
|
80
|
+
route.action # String: "show"
|
|
81
|
+
route.namespace # String o nil: "Admin::V2"
|
|
82
|
+
```
|