railsmith 1.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 +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +64 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +156 -0
- data/README.md +249 -0
- data/Rakefile +14 -0
- data/docs/cookbook.md +605 -0
- data/docs/legacy-adoption.md +283 -0
- data/docs/quickstart.md +110 -0
- data/lib/generators/railsmith/domain/domain_generator.rb +57 -0
- data/lib/generators/railsmith/domain/templates/domain.rb.tt +14 -0
- data/lib/generators/railsmith/install/install_generator.rb +21 -0
- data/lib/generators/railsmith/install/templates/railsmith.rb +10 -0
- data/lib/generators/railsmith/model_service/model_service_generator.rb +121 -0
- data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +28 -0
- data/lib/generators/railsmith/operation/operation_generator.rb +88 -0
- data/lib/generators/railsmith/operation/templates/operation.rb.tt +27 -0
- data/lib/railsmith/arch_checks/cli.rb +79 -0
- data/lib/railsmith/arch_checks/direct_model_access_checker.rb +94 -0
- data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +206 -0
- data/lib/railsmith/arch_checks/violation.rb +14 -0
- data/lib/railsmith/arch_checks.rb +7 -0
- data/lib/railsmith/arch_report.rb +96 -0
- data/lib/railsmith/base_service/bulk_actions.rb +77 -0
- data/lib/railsmith/base_service/bulk_contract.rb +56 -0
- data/lib/railsmith/base_service/bulk_execution.rb +68 -0
- data/lib/railsmith/base_service/bulk_params.rb +56 -0
- data/lib/railsmith/base_service/crud_actions.rb +63 -0
- data/lib/railsmith/base_service/crud_error_mapping.rb +78 -0
- data/lib/railsmith/base_service/crud_model_resolution.rb +36 -0
- data/lib/railsmith/base_service/crud_record_helpers.rb +60 -0
- data/lib/railsmith/base_service/crud_transactions.rb +31 -0
- data/lib/railsmith/base_service/domain_context_propagation.rb +29 -0
- data/lib/railsmith/base_service/dup_helpers.rb +15 -0
- data/lib/railsmith/base_service/validation.rb +67 -0
- data/lib/railsmith/base_service.rb +96 -0
- data/lib/railsmith/configuration.rb +18 -0
- data/lib/railsmith/cross_domain_guard.rb +90 -0
- data/lib/railsmith/cross_domain_warning_formatter.rb +66 -0
- data/lib/railsmith/deep_dup.rb +20 -0
- data/lib/railsmith/domain_context.rb +44 -0
- data/lib/railsmith/errors.rb +50 -0
- data/lib/railsmith/instrumentation.rb +64 -0
- data/lib/railsmith/railtie.rb +10 -0
- data/lib/railsmith/result.rb +60 -0
- data/lib/railsmith/version.rb +5 -0
- data/lib/railsmith.rb +31 -0
- data/lib/tasks/railsmith.rake +24 -0
- data/sig/railsmith.rbs +4 -0
- metadata +116 -0
data/docs/cookbook.md
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
# Railsmith Cookbook
|
|
2
|
+
|
|
3
|
+
Recipes for common patterns. Each section is self-contained.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## CRUD
|
|
8
|
+
|
|
9
|
+
### Default CRUD with no customization
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
rails generate railsmith:model_service Post
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# app/services/operations/post_service.rb
|
|
17
|
+
module Operations
|
|
18
|
+
class PostService < Railsmith::BaseService
|
|
19
|
+
model(Post)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The three default actions work immediately:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# Create
|
|
28
|
+
result = Operations::PostService.call(
|
|
29
|
+
action: :create,
|
|
30
|
+
params: { attributes: { title: "Hello", body: "World" } },
|
|
31
|
+
context: {}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Update
|
|
35
|
+
result = Operations::PostService.call(
|
|
36
|
+
action: :update,
|
|
37
|
+
params: { id: 1, attributes: { title: "Updated" } },
|
|
38
|
+
context: {}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Destroy
|
|
42
|
+
result = Operations::PostService.call(
|
|
43
|
+
action: :destroy,
|
|
44
|
+
params: { id: 1 },
|
|
45
|
+
context: {}
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### Customizing attribute extraction
|
|
52
|
+
|
|
53
|
+
Override `sanitize_attributes` to strip or transform attributes before the record is written:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
module Operations
|
|
57
|
+
class PostService < Railsmith::BaseService
|
|
58
|
+
model(Post)
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def sanitize_attributes(attributes)
|
|
63
|
+
attributes.except(:admin_override, :internal_flag)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Override `attributes_params` to change where attributes are read from:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
def attributes_params
|
|
73
|
+
params[:post] # instead of params[:attributes]
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### Custom finder logic
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
module Operations
|
|
83
|
+
class PostService < Railsmith::BaseService
|
|
84
|
+
model(Post)
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def find_record(model_klass, id)
|
|
89
|
+
model_klass.published.find_by(id: id)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### Custom action
|
|
98
|
+
|
|
99
|
+
Define a method with the action name. Use `Result` and `Errors` directly:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
module Operations
|
|
103
|
+
class PostService < Railsmith::BaseService
|
|
104
|
+
model(Post)
|
|
105
|
+
|
|
106
|
+
def publish
|
|
107
|
+
id = params[:id]
|
|
108
|
+
return Result.failure(error: Errors.validation_error(details: { missing: ["id"] })) unless id
|
|
109
|
+
|
|
110
|
+
post = Post.find_by(id: id)
|
|
111
|
+
return Result.failure(error: Errors.not_found(message: "Post not found", details: { id: id })) unless post
|
|
112
|
+
|
|
113
|
+
return Result.failure(error: Errors.conflict(message: "Already published")) if post.published?
|
|
114
|
+
|
|
115
|
+
post.update!(published_at: Time.current)
|
|
116
|
+
Result.success(value: post)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Call it the same way:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
result = Operations::PostService.call(
|
|
126
|
+
action: :publish,
|
|
127
|
+
params: { id: 42 },
|
|
128
|
+
context: {}
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### Chaining service calls
|
|
135
|
+
|
|
136
|
+
Pass `context` through to preserve domain tracking:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
module Operations
|
|
140
|
+
class OrderService < Railsmith::BaseService
|
|
141
|
+
model(Order)
|
|
142
|
+
|
|
143
|
+
def place
|
|
144
|
+
# Validate stock via another service
|
|
145
|
+
stock_result = Operations::InventoryService.call(
|
|
146
|
+
action: :reserve,
|
|
147
|
+
params: { sku: params[:sku], qty: params[:qty] },
|
|
148
|
+
context: context # forward the same context
|
|
149
|
+
)
|
|
150
|
+
return stock_result if stock_result.failure?
|
|
151
|
+
|
|
152
|
+
result = Operations::OrderService.call(
|
|
153
|
+
action: :create,
|
|
154
|
+
params: { attributes: { sku: params[:sku], qty: params[:qty] } },
|
|
155
|
+
context: context
|
|
156
|
+
)
|
|
157
|
+
result
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Bulk Operations
|
|
166
|
+
|
|
167
|
+
### bulk_create
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
result = Operations::UserService.call(
|
|
171
|
+
action: :bulk_create,
|
|
172
|
+
params: {
|
|
173
|
+
items: [
|
|
174
|
+
{ name: "Alice", email: "alice@example.com" },
|
|
175
|
+
{ name: "Bob", email: "bob@example.com" }
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
context: {}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
summary = result.value[:summary]
|
|
182
|
+
# => { total: 2, success_count: 2, failure_count: 0, all_succeeded: true }
|
|
183
|
+
|
|
184
|
+
result.value[:items].each do |item|
|
|
185
|
+
if item[:success]
|
|
186
|
+
puts "Created #{item[:value].id}"
|
|
187
|
+
else
|
|
188
|
+
puts "Failed item #{item[:index]}: #{item[:error][:message]}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
### bulk_update
|
|
196
|
+
|
|
197
|
+
Each item must include an `id` key and an `attributes` key:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
result = Operations::UserService.call(
|
|
201
|
+
action: :bulk_update,
|
|
202
|
+
params: {
|
|
203
|
+
items: [
|
|
204
|
+
{ id: 1, attributes: { name: "Alice Smith" } },
|
|
205
|
+
{ id: 2, attributes: { name: "Bob Jones" } }
|
|
206
|
+
]
|
|
207
|
+
},
|
|
208
|
+
context: {}
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
### bulk_destroy
|
|
215
|
+
|
|
216
|
+
Pass IDs directly or as hashes:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# Array of IDs
|
|
220
|
+
result = Operations::UserService.call(
|
|
221
|
+
action: :bulk_destroy,
|
|
222
|
+
params: { items: [1, 2, 3] },
|
|
223
|
+
context: {}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Array of hashes
|
|
227
|
+
result = Operations::UserService.call(
|
|
228
|
+
action: :bulk_destroy,
|
|
229
|
+
params: { items: [{ id: 1 }, { id: 2 }] },
|
|
230
|
+
context: {}
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
### Transaction modes
|
|
237
|
+
|
|
238
|
+
| Mode | Behavior |
|
|
239
|
+
|------|----------|
|
|
240
|
+
| `:best_effort` (default) | Each item in its own transaction. Partial success is persisted. |
|
|
241
|
+
| `:all_or_nothing` | All items in one transaction. Any failure rolls back the entire batch. |
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# All-or-nothing import
|
|
245
|
+
result = Operations::UserService.call(
|
|
246
|
+
action: :bulk_create,
|
|
247
|
+
params: {
|
|
248
|
+
items: rows,
|
|
249
|
+
transaction_mode: :all_or_nothing,
|
|
250
|
+
limit: 500,
|
|
251
|
+
batch_size: 50
|
|
252
|
+
},
|
|
253
|
+
context: {}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
unless result.value[:summary][:all_succeeded]
|
|
257
|
+
# Roll back handled automatically; inspect failures:
|
|
258
|
+
result.value[:items].select { |i| !i[:success] }.each do |i|
|
|
259
|
+
puts "Row #{i[:index]}: #{i[:error][:message]}"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
### Bulk result shape
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
result.value
|
|
270
|
+
# {
|
|
271
|
+
# operation: "bulk_create",
|
|
272
|
+
# transaction_mode: "best_effort",
|
|
273
|
+
# items: [
|
|
274
|
+
# { index: 0, input: {...}, success: true, value: <User>, error: nil },
|
|
275
|
+
# { index: 1, input: {...}, success: false, value: nil, error: {...} }
|
|
276
|
+
# ],
|
|
277
|
+
# summary: {
|
|
278
|
+
# total: 2,
|
|
279
|
+
# success_count: 1,
|
|
280
|
+
# failure_count: 1,
|
|
281
|
+
# all_succeeded: false
|
|
282
|
+
# }
|
|
283
|
+
# }
|
|
284
|
+
|
|
285
|
+
result.meta
|
|
286
|
+
# { model: "User", operation: "bulk_create", transaction_mode: "best_effort", limit: 1000 }
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Domain Context and Boundaries
|
|
292
|
+
|
|
293
|
+
### Declare a domain
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
rails generate railsmith:domain Billing
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Creates `app/domains/billing.rb` and subdirectories `app/domains/billing/operations/` and `app/domains/billing/services/`.
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
rails generate railsmith:model_service Billing::Invoice --domain=Billing
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Creates `app/domains/billing/services/invoice_service.rb`:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
module Billing
|
|
309
|
+
module Services
|
|
310
|
+
class InvoiceService < Railsmith::BaseService
|
|
311
|
+
model(Billing::Invoice)
|
|
312
|
+
service_domain :billing
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### Pass domain context on every call
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
ctx = Railsmith::DomainContext.new(
|
|
324
|
+
current_domain: :billing,
|
|
325
|
+
meta: { request_id: "req-abc123", actor_id: current_user.id }
|
|
326
|
+
).to_h
|
|
327
|
+
|
|
328
|
+
result = Billing::Services::InvoiceService.call(
|
|
329
|
+
action: :create,
|
|
330
|
+
params: { attributes: { amount: 100_00, currency: "USD" } },
|
|
331
|
+
context: ctx
|
|
332
|
+
)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`DomainContext#to_h` merges `current_domain` and all `meta` keys into one flat hash, which is what `context:` expects.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
### Cross-domain detection
|
|
340
|
+
|
|
341
|
+
When `current_domain` in the context differs from a service's declared `service_domain`, Railsmith emits a warning. By default this is non-blocking.
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
# context says :catalog, service declares :billing — warning fires
|
|
345
|
+
result = Billing::Services::InvoiceService.call(
|
|
346
|
+
action: :create,
|
|
347
|
+
params: { ... },
|
|
348
|
+
context: { current_domain: :catalog }
|
|
349
|
+
)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Subscribe to cross-domain warnings for logging:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
Railsmith::Instrumentation.subscribe("cross_domain.warning.railsmith") do |_event, payload|
|
|
356
|
+
Rails.logger.warn("[cross-domain] #{payload.inspect}")
|
|
357
|
+
end
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### Configure domain enforcement
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
# config/initializers/railsmith.rb
|
|
366
|
+
Railsmith.configure do |config|
|
|
367
|
+
# Warn on all cross-domain calls (default: true)
|
|
368
|
+
config.warn_on_cross_domain_calls = true
|
|
369
|
+
|
|
370
|
+
# Strict mode: run a custom hook on every violation
|
|
371
|
+
config.strict_mode = true
|
|
372
|
+
config.on_cross_domain_violation = ->(payload) {
|
|
373
|
+
Honeybadger.notify("Cross-domain call", context: payload)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# Allowlist known approved cross-domain pairs
|
|
377
|
+
config.cross_domain_allowlist = [
|
|
378
|
+
{ from: :catalog, to: :billing },
|
|
379
|
+
[:shipping, :inventory]
|
|
380
|
+
]
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Allowlisted pairs do not emit warnings.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
### Generate a domain operation
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
rails generate railsmith:operation Billing::Invoices::Finalize
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Creates `app/domains/billing/operations/invoices/finalize.rb`:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
module Billing
|
|
398
|
+
module Operations
|
|
399
|
+
module Invoices
|
|
400
|
+
class Finalize
|
|
401
|
+
def self.call(params: {}, context: {})
|
|
402
|
+
new(params:, context:).call
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
attr_reader :params, :context
|
|
406
|
+
|
|
407
|
+
def initialize(params:, context:)
|
|
408
|
+
@params = Railsmith.deep_dup(params || {})
|
|
409
|
+
@context = Railsmith.deep_dup(context || {})
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def call
|
|
413
|
+
current_domain = Railsmith::DomainContext.normalize_current_domain(context[:current_domain])
|
|
414
|
+
# Your logic here
|
|
415
|
+
Railsmith::Result.success(value: { current_domain: current_domain })
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Error Mapping
|
|
426
|
+
|
|
427
|
+
### Error types
|
|
428
|
+
|
|
429
|
+
| Code | Factory method | Typical trigger |
|
|
430
|
+
|------|---------------|-----------------|
|
|
431
|
+
| `validation_error` | `Errors.validation_error(...)` | ActiveModel validation failure |
|
|
432
|
+
| `not_found` | `Errors.not_found(...)` | `find_by` returns nil |
|
|
433
|
+
| `conflict` | `Errors.conflict(...)` | Duplicate unique constraint |
|
|
434
|
+
| `unauthorized` | `Errors.unauthorized(...)` | Permission check fails |
|
|
435
|
+
| `unexpected` | `Errors.unexpected(...)` | Unhandled exception |
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
### Building errors manually
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
# Validation
|
|
443
|
+
error = Railsmith::Errors.validation_error(
|
|
444
|
+
message: "Validation failed",
|
|
445
|
+
details: { errors: { email: ["is invalid"], name: ["can't be blank"] } }
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Not found
|
|
449
|
+
error = Railsmith::Errors.not_found(
|
|
450
|
+
message: "Invoice not found",
|
|
451
|
+
details: { model: "Invoice", id: 99 }
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Conflict
|
|
455
|
+
error = Railsmith::Errors.conflict(
|
|
456
|
+
message: "Email already taken",
|
|
457
|
+
details: { field: "email" }
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Unauthorized
|
|
461
|
+
error = Railsmith::Errors.unauthorized(
|
|
462
|
+
message: "Admin access required",
|
|
463
|
+
details: { required_role: "admin" }
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Unexpected
|
|
467
|
+
error = Railsmith::Errors.unexpected(
|
|
468
|
+
message: "Stripe API unavailable",
|
|
469
|
+
details: { exception_class: "Stripe::APIConnectionError" }
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Wrap in a Result
|
|
473
|
+
result = Railsmith::Result.failure(error: error)
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
### Automatic exception mapping (built into default CRUD)
|
|
479
|
+
|
|
480
|
+
The default `create`, `update`, and `destroy` actions catch and map these exceptions automatically — you don't need to rescue them yourself:
|
|
481
|
+
|
|
482
|
+
| Exception | Mapped code |
|
|
483
|
+
|-----------|-------------|
|
|
484
|
+
| `ActiveRecord::RecordNotFound` | `not_found` |
|
|
485
|
+
| `ActiveRecord::RecordInvalid` | `validation_error` (with `record.errors`) |
|
|
486
|
+
| `ActiveRecord::RecordNotUnique` | `conflict` |
|
|
487
|
+
| `ActiveRecord::StaleObjectError` | `conflict` |
|
|
488
|
+
| Any other exception | `unexpected` |
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
### Consuming errors in a controller
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
class UsersController < ApplicationController
|
|
496
|
+
def create
|
|
497
|
+
result = Operations::UserService.call(
|
|
498
|
+
action: :create,
|
|
499
|
+
params: { attributes: user_params },
|
|
500
|
+
context: { current_domain: :identity }
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if result.success?
|
|
504
|
+
render json: result.value, status: :created
|
|
505
|
+
else
|
|
506
|
+
status = case result.code
|
|
507
|
+
when "validation_error" then :unprocessable_entity
|
|
508
|
+
when "not_found" then :not_found
|
|
509
|
+
when "conflict" then :conflict
|
|
510
|
+
when "unauthorized" then :forbidden
|
|
511
|
+
else :internal_server_error
|
|
512
|
+
end
|
|
513
|
+
render json: result.error.to_h, status: status
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private
|
|
518
|
+
|
|
519
|
+
def user_params
|
|
520
|
+
params.require(:user).permit(:name, :email)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
### Inline validation with required keys
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
def register
|
|
531
|
+
val = validate(params, required_keys: [:email, :password])
|
|
532
|
+
return val if val.failure?
|
|
533
|
+
|
|
534
|
+
# ... proceed
|
|
535
|
+
end
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
`validate` returns `Result.failure` with a `validation_error` if any key is missing, or `Result.success` otherwise.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Observability
|
|
543
|
+
|
|
544
|
+
### Subscribe to service call events
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
Railsmith::Instrumentation.subscribe("service.call.railsmith") do |_event, payload|
|
|
548
|
+
Rails.logger.info(
|
|
549
|
+
"[railsmith] #{payload[:service]}##{payload[:action]} domain=#{payload[:domain]}"
|
|
550
|
+
)
|
|
551
|
+
end
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Subscribe to cross-domain warnings
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
Railsmith::Instrumentation.subscribe("cross_domain.warning.railsmith") do |_event, payload|
|
|
558
|
+
Rails.logger.warn("[railsmith:cross-domain] #{payload.inspect}")
|
|
559
|
+
end
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## Architecture Checks
|
|
565
|
+
|
|
566
|
+
Run static analysis to find controllers directly touching models (and actions missing service usage, per the bundled checkers):
|
|
567
|
+
|
|
568
|
+
```bash
|
|
569
|
+
rake railsmith:arch_check
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
With options:
|
|
573
|
+
|
|
574
|
+
```bash
|
|
575
|
+
# JSON output
|
|
576
|
+
RAILSMITH_FORMAT=json rake railsmith:arch_check
|
|
577
|
+
|
|
578
|
+
# Check additional paths
|
|
579
|
+
RAILSMITH_PATHS=app/controllers,app/jobs rake railsmith:arch_check
|
|
580
|
+
|
|
581
|
+
# Fail CI on violations
|
|
582
|
+
RAILSMITH_FAIL_ON_ARCH_VIOLATIONS=true rake railsmith:arch_check
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
The Rake task wraps `Railsmith::ArchChecks::Cli.run`. Use that when you need the same scan in Ruby (for example, to capture output or run with a custom env hash):
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
require "railsmith/arch_checks"
|
|
589
|
+
require "stringio"
|
|
590
|
+
|
|
591
|
+
out = StringIO.new
|
|
592
|
+
status = Railsmith::ArchChecks::Cli.run(
|
|
593
|
+
env: ENV.to_h.merge("RAILSMITH_PATHS" => "app/controllers", "RAILSMITH_FORMAT" => "json"),
|
|
594
|
+
output: out
|
|
595
|
+
)
|
|
596
|
+
# status is 0 or 1; report body is out.string
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Or set the default fail-on behaviour in the initializer:
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
Railsmith.configure do |config|
|
|
603
|
+
config.fail_on_arch_violations = true
|
|
604
|
+
end
|
|
605
|
+
```
|