rails-hyperdrive-layered-rails 0.1.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 +7 -0
- data/LICENSE +30 -0
- data/README.md +89 -0
- data/UPSTREAM +4 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/SKILL.md.erb +315 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/authorization-to-policy.md +79 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/callbacks-to-service.md +72 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/complex-input-to-form-object.md +119 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/current-from-model.md +63 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/god-object-decomposition.md +81 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/implicit-to-explicit-state-machine.md +77 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/query-to-query-object.md +63 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/view-logic-to-presenter.md +60 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/callbacks.md +114 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/concerns.md +92 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/helpers.md +66 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/jobs.md +71 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/layer-violations.md +176 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/service-objects.md +131 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/testing.md +37 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/architecture-layers.md +204 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/extraction-signals.md +314 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/specification-test.md +229 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/action-policy.md +437 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-agent.md +275 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-delivery.md +221 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-job-performs.md +171 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/alba.md +257 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/anyway-config.md +212 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/rubanok.md +246 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/view-component.md +223 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/workflow.md +305 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/collaborator-objects.md +209 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/concerns.md +381 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/filter-objects.md +222 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/form-objects.md +267 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/policy-objects.md +298 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/presenters.md +257 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/query-objects.md +208 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/repositories.md +382 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/serializers.md +256 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/service-objects.md +170 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/state-machines.md +368 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/value-objects.md +267 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/ai-integration.md +372 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/authorization.md +398 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/callbacks.md +336 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/configuration.md +383 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/current-attributes.md +307 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/instrumentation.md +386 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/notifications.md +373 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/view-components.md +454 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-callbacks.md +255 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-gods.md +279 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-services.md +1444 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze.md +376 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/plan.md +249 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/review.md +473 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/spec-test.md +282 -0
- data/lib/rails-hyperdrive-layered-rails/version.rb +3 -0
- data/lib/rails-hyperdrive-layered-rails.rb +7 -0
- metadata +106 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# Action Policy
|
|
2
|
+
|
|
3
|
+
Authorization framework for Ruby and Rails applications.
|
|
4
|
+
|
|
5
|
+
**GitHub**: https://github.com/palkan/action_policy
|
|
6
|
+
**Layer**: Application
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Installation
|
|
11
|
+
- Basic Usage
|
|
12
|
+
- Scoping
|
|
13
|
+
- Rule Aliases
|
|
14
|
+
- Minimal CRUD Rules
|
|
15
|
+
- Reason Tracking with `check?`
|
|
16
|
+
- Policy Composition
|
|
17
|
+
- Pre-Checks
|
|
18
|
+
- Authorization Context
|
|
19
|
+
- Error Handling
|
|
20
|
+
- Gotchas
|
|
21
|
+
- Testing
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Gemfile
|
|
27
|
+
gem "action_policy"
|
|
28
|
+
|
|
29
|
+
# Generate policy
|
|
30
|
+
rails generate action_policy:policy Post
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Basic Usage
|
|
34
|
+
|
|
35
|
+
### Define Policy
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class PostPolicy < ApplicationPolicy
|
|
39
|
+
def show?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def update?
|
|
44
|
+
owner? || user.admin?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def destroy?
|
|
48
|
+
user.admin?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def owner?
|
|
54
|
+
record.author_id == user.id
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Controller Integration
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class PostsController < ApplicationController
|
|
63
|
+
def show
|
|
64
|
+
@post = Post.find(params[:id])
|
|
65
|
+
authorize! @post
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update
|
|
69
|
+
@post = Post.find(params[:id])
|
|
70
|
+
authorize! @post
|
|
71
|
+
|
|
72
|
+
@post.update!(post_params)
|
|
73
|
+
redirect_to @post
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### View Integration
|
|
79
|
+
|
|
80
|
+
```erb
|
|
81
|
+
<% if allowed_to?(:update?, @post) %>
|
|
82
|
+
<%= link_to "Edit", edit_post_path(@post) %>
|
|
83
|
+
<% end %>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Scoping
|
|
87
|
+
|
|
88
|
+
Load only authorized records:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class PostsController < ApplicationController
|
|
92
|
+
def index
|
|
93
|
+
@posts = authorized_scope(Post.all)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def destroy
|
|
97
|
+
@post = authorized_scope(Post.all, as: :destroyable).find(params[:id])
|
|
98
|
+
@post.destroy!
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class PostPolicy < ApplicationPolicy
|
|
103
|
+
# Default scope for index
|
|
104
|
+
relation_scope do |scope|
|
|
105
|
+
if user.admin?
|
|
106
|
+
scope.all
|
|
107
|
+
else
|
|
108
|
+
scope.published.or(scope.where(author: user))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Named scope
|
|
113
|
+
relation_scope(:destroyable) do |scope|
|
|
114
|
+
user.admin? ? scope.all : scope.where(author: user)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Rule Aliases
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class PostPolicy < ApplicationPolicy
|
|
123
|
+
def manage?
|
|
124
|
+
owner? || user.admin?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Alias multiple rules to manage?
|
|
128
|
+
alias_rule :create?, :update?, :destroy?, to: :manage?
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Minimal CRUD Rules
|
|
133
|
+
|
|
134
|
+
Keep policies small. Most policies need only two rules:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class BoardPolicy < ApplicationPolicy
|
|
138
|
+
def show?
|
|
139
|
+
# Visibility - who can see this resource
|
|
140
|
+
record.all_access? || has_access?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def manage?
|
|
144
|
+
# Management - who can modify this resource
|
|
145
|
+
board_admin?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# update?, edit?, destroy? automatically fall back to manage?
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Action Policy provides:
|
|
153
|
+
- **Default rule:** `manage?` as fallback for undefined rules
|
|
154
|
+
- **Default alias:** `new? -> create?`
|
|
155
|
+
|
|
156
|
+
### When Custom Rules Make Sense
|
|
157
|
+
|
|
158
|
+
Custom rules are appropriate for:
|
|
159
|
+
|
|
160
|
+
**Domain operations** beyond CRUD:
|
|
161
|
+
```ruby
|
|
162
|
+
class AccountPolicy < ApplicationPolicy
|
|
163
|
+
def transfer?
|
|
164
|
+
owner? && record.transferable?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def cancel?
|
|
168
|
+
owner? || admin?
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class ReportPolicy < ApplicationPolicy
|
|
173
|
+
def show_sources?
|
|
174
|
+
allowed_to?(:show?) && user.researcher?
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Parent policy groupings** for related resources:
|
|
180
|
+
```ruby
|
|
181
|
+
class OrganizationPolicy < ApplicationPolicy
|
|
182
|
+
def manage_billing?
|
|
183
|
+
owner? || billing_admin?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def manage_members?
|
|
187
|
+
owner? || admin?
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Used across multiple controllers
|
|
192
|
+
authorize! @organization, to: :manage_billing?
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### When to Consider New Resources
|
|
196
|
+
|
|
197
|
+
If a custom rule maps directly to a controller action with its own views and routes, consider a dedicated resource:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# If you have:
|
|
201
|
+
# POST /cards/:id/archive
|
|
202
|
+
# GET /cards/:id/archive (confirmation page)
|
|
203
|
+
# DELETE /cards/:id/archive (unarchive)
|
|
204
|
+
|
|
205
|
+
# Consider: Cards::ArchivesController with ArchivePolicy
|
|
206
|
+
# Instead of: CardPolicy#can_archive?, #can_unarchive?
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The goal is clarity, not strict adherence to patterns.
|
|
210
|
+
|
|
211
|
+
## Reason Tracking with `check?`
|
|
212
|
+
|
|
213
|
+
Use `check?` instead of direct predicate calls to track failure reasons:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
class AccountPolicy < ApplicationPolicy
|
|
217
|
+
def manage?
|
|
218
|
+
check?(:admin?) # Tracks "admin?" as failure reason
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def destroy?
|
|
222
|
+
check?(:owner?) # Tracks "owner?" as failure reason
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
This enables debugging authorization failures:
|
|
228
|
+
```ruby
|
|
229
|
+
policy = AccountPolicy.new(user, account)
|
|
230
|
+
policy.apply(:manage?)
|
|
231
|
+
policy.result.reasons #=> { admin?: false }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Policy Composition
|
|
235
|
+
|
|
236
|
+
Delegate to other policies for nested authorization:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
class CommentPolicy < ApplicationPolicy
|
|
240
|
+
authorize :card, optional: true
|
|
241
|
+
|
|
242
|
+
def create?
|
|
243
|
+
allowed_to?(:show?, card) # Delegates to CardPolicy
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def show?
|
|
247
|
+
allowed_to?(:show?, record.card) # Uses record's association
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def manage?
|
|
251
|
+
allowed_to?(:show?) && check?(:creator?) # Combines both
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Pre-Checks
|
|
257
|
+
|
|
258
|
+
Run before every rule:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class ApplicationPolicy < ActionPolicy::Base
|
|
262
|
+
pre_check :allow_admins
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def allow_admins
|
|
267
|
+
allow! if user.admin?
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Authorization Context
|
|
273
|
+
|
|
274
|
+
For `create?` and `index?` rules where `record` isn't available, use authorization contexts:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
class WebhookPolicy < ApplicationPolicy
|
|
278
|
+
authorize :board, optional: true # optional: only needed for create?/index?
|
|
279
|
+
|
|
280
|
+
def index?
|
|
281
|
+
allowed_to?(:manage?, board) # Uses context
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def create?
|
|
285
|
+
allowed_to?(:manage?, board) # Uses context
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def show?
|
|
289
|
+
allowed_to?(:manage?, record.board) # Uses record
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Register contexts via controller concerns:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
module BoardScoped
|
|
298
|
+
extend ActiveSupport::Concern
|
|
299
|
+
|
|
300
|
+
included do
|
|
301
|
+
before_action :set_board
|
|
302
|
+
authorize :board, through: -> { @board }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def set_board
|
|
308
|
+
@board = Current.account.boards.find_by!(slug: params[:board_id])
|
|
309
|
+
authorize! @board, to: :show?
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
class WebhooksController < ApplicationController
|
|
314
|
+
include BoardScoped
|
|
315
|
+
|
|
316
|
+
def index
|
|
317
|
+
authorize! # Uses WebhookPolicy with board context
|
|
318
|
+
@webhooks = @board.webhooks
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Error Handling
|
|
324
|
+
|
|
325
|
+
Return 404 for visibility failures (don't leak existence), 403 for permission failures:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
module Authorization
|
|
329
|
+
extend ActiveSupport::Concern
|
|
330
|
+
|
|
331
|
+
included do
|
|
332
|
+
rescue_from ActionPolicy::Unauthorized, with: :handle_unauthorized
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
336
|
+
|
|
337
|
+
def handle_unauthorized(exception)
|
|
338
|
+
if exception.rule == :show?
|
|
339
|
+
raise ActiveRecord::RecordNotFound # 404
|
|
340
|
+
else
|
|
341
|
+
head :forbidden # 403
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Gotchas
|
|
348
|
+
|
|
349
|
+
### View Context Syntax
|
|
350
|
+
|
|
351
|
+
Context must use `context:` hash:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
# Wrong
|
|
355
|
+
allowed_to?(:create?, Webhook, board: @board)
|
|
356
|
+
|
|
357
|
+
# Correct
|
|
358
|
+
allowed_to?(:create?, Webhook, context: {board: @board})
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Optional Contexts
|
|
362
|
+
|
|
363
|
+
Mark context as `optional: true` when only needed for some rules:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
authorize :board, optional: true # Required for create?, not show?
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Testing
|
|
370
|
+
|
|
371
|
+
### Policy Unit Tests
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# RSpec
|
|
375
|
+
RSpec.describe PostPolicy do
|
|
376
|
+
let(:user) { create(:user) }
|
|
377
|
+
let(:post) { create(:post) }
|
|
378
|
+
|
|
379
|
+
describe "#update?" do
|
|
380
|
+
it "allows owner" do
|
|
381
|
+
post = create(:post, author: user)
|
|
382
|
+
expect(described_class.new(user, post).update?).to be true
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it "denies non-owner" do
|
|
386
|
+
expect(described_class.new(user, post).update?).to be false
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Minitest
|
|
392
|
+
class BoardPolicyTest < ActiveSupport::TestCase
|
|
393
|
+
test "board creator can manage" do
|
|
394
|
+
policy = BoardPolicy.new(@board, user: users(:david), account: @account)
|
|
395
|
+
assert policy.apply(:manage?)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
test "create uses board context" do
|
|
399
|
+
# Pass context as named argument, no record
|
|
400
|
+
policy = WebhookPolicy.new(board: @board, user: users(:david), account: @account)
|
|
401
|
+
assert policy.apply(:create?)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Controller Authorization Tests
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
# RSpec
|
|
410
|
+
RSpec.describe PostsController do
|
|
411
|
+
include ActionPolicy::TestHelper
|
|
412
|
+
|
|
413
|
+
it "authorizes update" do
|
|
414
|
+
expect {
|
|
415
|
+
put :update, params: { id: post.id, post: { title: "New" } }
|
|
416
|
+
}.to be_authorized_to(:update?, post)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Minitest
|
|
421
|
+
class WebhooksControllerTest < ActionDispatch::IntegrationTest
|
|
422
|
+
include ActionPolicy::TestHelper
|
|
423
|
+
|
|
424
|
+
test "index authorizes" do
|
|
425
|
+
assert_authorized_to(:index?, Webhook) do
|
|
426
|
+
get board_webhooks_path(boards(:writebook))
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
test "show authorizes specific record" do
|
|
431
|
+
webhook = webhooks(:active)
|
|
432
|
+
assert_authorized_to(:show?, webhook) do
|
|
433
|
+
get board_webhook_path(webhook.board, webhook)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
```
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Active Agent
|
|
2
|
+
|
|
3
|
+
Framework for building AI agents in Rails applications.
|
|
4
|
+
|
|
5
|
+
**GitHub**: https://github.com/activeagents/activeagent
|
|
6
|
+
**Layer**: Infrastructure (LLM calls) / Application (orchestration)
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Installation
|
|
11
|
+
- Basic Usage
|
|
12
|
+
- Agents with Tools
|
|
13
|
+
- System Prompts
|
|
14
|
+
- Structured Output
|
|
15
|
+
- Service Integration
|
|
16
|
+
- Background Processing
|
|
17
|
+
- Configuration
|
|
18
|
+
- Testing
|
|
19
|
+
- Error Handling
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem "activeagent"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Basic Usage
|
|
29
|
+
|
|
30
|
+
### Define Agent
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# app/agents/application_agent.rb
|
|
34
|
+
class ApplicationAgent < ActiveAgent::Base
|
|
35
|
+
# Default provider configuration
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# app/agents/summarizer_agent.rb
|
|
39
|
+
class SummarizerAgent < ApplicationAgent
|
|
40
|
+
def summarize(text, max_length: 200)
|
|
41
|
+
prompt <<~PROMPT
|
|
42
|
+
Summarize the following text in #{max_length} characters or less.
|
|
43
|
+
Maintain the key points and tone.
|
|
44
|
+
|
|
45
|
+
Text:
|
|
46
|
+
#{text}
|
|
47
|
+
PROMPT
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Use Agent
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
agent = SummarizerAgent.new
|
|
56
|
+
summary = agent.summarize(article.body, max_length: 150)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Agents with Tools
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class ResearchAgent < ApplicationAgent
|
|
63
|
+
tool :search_web,
|
|
64
|
+
description: "Search the web for information" do |query|
|
|
65
|
+
WebSearchService.search(query).map do |result|
|
|
66
|
+
{ title: result.title, url: result.url, snippet: result.snippet }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
tool :read_url,
|
|
71
|
+
description: "Read and extract content from a URL" do |url|
|
|
72
|
+
UrlReaderService.read(url)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
tool :save_note,
|
|
76
|
+
description: "Save a research note" do |title, content|
|
|
77
|
+
ResearchNote.create!(title: title, content: content)
|
|
78
|
+
"Note saved: #{title}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def research(topic)
|
|
82
|
+
prompt <<~PROMPT
|
|
83
|
+
Research the topic: #{topic}
|
|
84
|
+
|
|
85
|
+
Use the search_web tool to find relevant information.
|
|
86
|
+
Use read_url to get more details from promising results.
|
|
87
|
+
Use save_note to record important findings.
|
|
88
|
+
|
|
89
|
+
Provide a comprehensive summary of your research.
|
|
90
|
+
PROMPT
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## System Prompts
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class CustomerSupportAgent < ApplicationAgent
|
|
99
|
+
system_prompt <<~PROMPT
|
|
100
|
+
You are a helpful customer support agent for Acme Corp.
|
|
101
|
+
Be polite, professional, and helpful.
|
|
102
|
+
If you don't know something, say so honestly.
|
|
103
|
+
Never make up information about products or policies.
|
|
104
|
+
PROMPT
|
|
105
|
+
|
|
106
|
+
def respond(customer_message, context: {})
|
|
107
|
+
prompt <<~PROMPT
|
|
108
|
+
Customer: #{customer_message}
|
|
109
|
+
|
|
110
|
+
Context:
|
|
111
|
+
- Customer name: #{context[:customer_name]}
|
|
112
|
+
- Account type: #{context[:account_type]}
|
|
113
|
+
- Previous interactions: #{context[:interaction_count]}
|
|
114
|
+
|
|
115
|
+
Respond helpfully to the customer's message.
|
|
116
|
+
PROMPT
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Structured Output
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class ClassifierAgent < ApplicationAgent
|
|
125
|
+
def classify(content)
|
|
126
|
+
response = prompt <<~PROMPT
|
|
127
|
+
Classify the following content into one of these categories:
|
|
128
|
+
- technology
|
|
129
|
+
- business
|
|
130
|
+
- entertainment
|
|
131
|
+
- sports
|
|
132
|
+
- science
|
|
133
|
+
|
|
134
|
+
Also provide a confidence score from 0 to 1.
|
|
135
|
+
|
|
136
|
+
Content: #{content}
|
|
137
|
+
|
|
138
|
+
Respond in JSON format:
|
|
139
|
+
{"category": "...", "confidence": 0.XX}
|
|
140
|
+
PROMPT
|
|
141
|
+
|
|
142
|
+
JSON.parse(response)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Service Integration
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class SummarizeArticle
|
|
151
|
+
def initialize(agent: SummarizerAgent.new)
|
|
152
|
+
@agent = agent
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def call(article)
|
|
156
|
+
return article.summary if article.summary.present?
|
|
157
|
+
|
|
158
|
+
summary = agent.summarize(article.body)
|
|
159
|
+
article.update!(summary: summary, summarized_at: Time.current)
|
|
160
|
+
summary
|
|
161
|
+
rescue ActiveAgent::Error => e
|
|
162
|
+
Rails.logger.error("Summarization failed: #{e.message}")
|
|
163
|
+
article.body.truncate(200) # Fallback
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
attr_reader :agent
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Background Processing
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class SummarizeArticleJob < ApplicationJob
|
|
176
|
+
retry_on ActiveAgent::RateLimitError,
|
|
177
|
+
wait: :polynomially_longer,
|
|
178
|
+
attempts: 5
|
|
179
|
+
|
|
180
|
+
discard_on ActiveAgent::InvalidRequestError
|
|
181
|
+
|
|
182
|
+
def perform(article_id)
|
|
183
|
+
article = Article.find(article_id)
|
|
184
|
+
SummarizeArticle.new.call(article)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Configuration
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# config/initializers/active_agent.rb
|
|
193
|
+
ActiveAgent.configure do |config|
|
|
194
|
+
config.default_provider = :anthropic
|
|
195
|
+
config.default_model = "claude-3-sonnet"
|
|
196
|
+
|
|
197
|
+
config.providers[:anthropic] = {
|
|
198
|
+
api_key: Rails.application.credentials.anthropic_api_key
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
config.providers[:openai] = {
|
|
202
|
+
api_key: Rails.application.credentials.openai_api_key
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Testing
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
RSpec.describe SummarizeArticle do
|
|
211
|
+
let(:article) { create(:article, body: "Long article content...") }
|
|
212
|
+
|
|
213
|
+
describe "#call" do
|
|
214
|
+
it "generates and saves summary" do
|
|
215
|
+
mock_agent = instance_double(SummarizerAgent)
|
|
216
|
+
allow(mock_agent).to receive(:summarize).and_return("Brief summary")
|
|
217
|
+
|
|
218
|
+
service = described_class.new(agent: mock_agent)
|
|
219
|
+
result = service.call(article)
|
|
220
|
+
|
|
221
|
+
expect(result).to eq("Brief summary")
|
|
222
|
+
expect(article.reload.summary).to eq("Brief summary")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "handles API errors gracefully" do
|
|
226
|
+
mock_agent = instance_double(SummarizerAgent)
|
|
227
|
+
allow(mock_agent).to receive(:summarize)
|
|
228
|
+
.and_raise(ActiveAgent::RateLimitError)
|
|
229
|
+
|
|
230
|
+
service = described_class.new(agent: mock_agent)
|
|
231
|
+
result = service.call(article)
|
|
232
|
+
|
|
233
|
+
expect(result).to eq(article.body.truncate(200))
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
RSpec.describe ResearchAgent do
|
|
239
|
+
describe "#research" do
|
|
240
|
+
it "uses tools to gather information" do
|
|
241
|
+
agent = described_class.new
|
|
242
|
+
|
|
243
|
+
# Mock tool responses
|
|
244
|
+
allow(WebSearchService).to receive(:search)
|
|
245
|
+
.and_return([{ title: "Result", url: "http://...", snippet: "..." }])
|
|
246
|
+
|
|
247
|
+
result = agent.research("Ruby on Rails best practices")
|
|
248
|
+
|
|
249
|
+
expect(result).to be_present
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Error Handling
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
class AIService
|
|
259
|
+
def call(prompt)
|
|
260
|
+
agent.prompt(prompt)
|
|
261
|
+
rescue ActiveAgent::RateLimitError
|
|
262
|
+
# Retry after delay
|
|
263
|
+
sleep(60)
|
|
264
|
+
retry
|
|
265
|
+
rescue ActiveAgent::InvalidRequestError => e
|
|
266
|
+
# Log and return fallback
|
|
267
|
+
Rails.logger.error("Invalid AI request: #{e.message}")
|
|
268
|
+
nil
|
|
269
|
+
rescue ActiveAgent::AuthenticationError
|
|
270
|
+
# Critical - alert ops
|
|
271
|
+
Rails.logger.fatal("AI authentication failed")
|
|
272
|
+
raise
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
```
|