railsmith 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +236 -2
- data/MIGRATION.md +241 -1
- data/README.md +103 -3
- data/docs/associations.md +206 -0
- data/docs/call-bang.md +123 -0
- data/docs/cookbook.md +277 -6
- data/docs/inputs.md +212 -0
- data/lib/generators/railsmith/install/install_generator.rb +0 -1
- data/lib/generators/railsmith/model_service/model_service_generator.rb +124 -14
- data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +21 -0
- data/lib/railsmith/arch_checks/cli.rb +9 -1
- data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +16 -4
- data/lib/railsmith/arch_report.rb +16 -5
- data/lib/railsmith/base_service/association_definition.rb +59 -0
- data/lib/railsmith/base_service/association_dsl.rb +92 -0
- data/lib/railsmith/base_service/association_registry.rb +46 -0
- data/lib/railsmith/base_service/bulk_actions.rb +11 -3
- data/lib/railsmith/base_service/bulk_params.rb +8 -0
- data/lib/railsmith/base_service/crud_actions.rb +40 -18
- data/lib/railsmith/base_service/crud_record_helpers.rb +1 -1
- data/lib/railsmith/base_service/eager_loading.rb +64 -0
- data/lib/railsmith/base_service/input_definition.rb +37 -0
- data/lib/railsmith/base_service/input_dsl.rb +117 -0
- data/lib/railsmith/base_service/input_registry.rb +46 -0
- data/lib/railsmith/base_service/input_resolver.rb +159 -0
- data/lib/railsmith/base_service/nested_writer/cascading_destroy.rb +80 -0
- data/lib/railsmith/base_service/nested_writer/nested_write/write_nested.rb +147 -0
- data/lib/railsmith/base_service/nested_writer/nested_write/write_nested_item.rb +60 -0
- data/lib/railsmith/base_service/nested_writer/nested_write.rb +16 -0
- data/lib/railsmith/base_service/nested_writer.rb +48 -0
- data/lib/railsmith/base_service/type_coercion.rb +117 -0
- data/lib/railsmith/base_service/validation.rb +9 -0
- data/lib/railsmith/base_service.rb +40 -12
- data/lib/railsmith/configuration.rb +16 -0
- data/lib/railsmith/controller_helpers.rb +42 -0
- data/lib/railsmith/cross_domain_guard.rb +11 -1
- data/lib/railsmith/failure.rb +27 -0
- data/lib/railsmith/version.rb +1 -1
- data/lib/railsmith.rb +2 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9b32b70ceece070f58ca5699fb4caecc906a4b3204f1fb0f898783272d396d9
|
|
4
|
+
data.tar.gz: eb1668ea34874567dec4f7a4fbdf56da93a4fad6268f15552e84ecf3fcc5fcc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2797fddf8357f2fbe33ac5b99d7a2b621921c53911039b6803d941734747e4688c931328fb6bdf45c4d3b0adffe1ad69088a198b30caff670cc11e8ec2690ee6
|
|
7
|
+
data.tar.gz: 327b3adb98fafc1de6170663c5655cd18277ae262b80f02d830ebe045843c882f983211e1049d3f4280d14d0a187f85018695f769c680f9b0f5a6929f07e2547
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,241 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
## [
|
|
10
|
+
## [1.2.0] — 2026-04-08
|
|
11
|
+
|
|
12
|
+
### Added — Declarative Inputs & Type Coercion
|
|
13
|
+
|
|
14
|
+
- **`input` DSL** — declare expected parameters with types, defaults, and constraints directly on any `BaseService` subclass:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class UserService < Railsmith::BaseService
|
|
18
|
+
model User
|
|
19
|
+
domain :identity
|
|
20
|
+
|
|
21
|
+
input :email, String, required: true
|
|
22
|
+
input :age, Integer, default: nil
|
|
23
|
+
input :role, String, in: %w[admin member guest], default: "member"
|
|
24
|
+
input :active, :boolean, default: true
|
|
25
|
+
input :metadata, Hash, default: -> { {} }
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **`Railsmith::BaseService::InputDefinition`** — frozen value object storing each input's `name`, `type`, `required`, `default` (static value or zero-arg lambda), `in_values`, and `transform`.
|
|
30
|
+
|
|
31
|
+
- **`Railsmith::BaseService::InputRegistry`** — ordered collection of `InputDefinition`s attached to a service class; deep-duped on inheritance so subclasses can extend or override without affecting the parent.
|
|
32
|
+
|
|
33
|
+
- **`Railsmith::BaseService::TypeCoercion`** — automatic type conversion before validation. Supported target types and strategies:
|
|
34
|
+
|
|
35
|
+
| Type | Behaviour |
|
|
36
|
+
|------|-----------|
|
|
37
|
+
| `String` | `value.to_s` |
|
|
38
|
+
| `Integer` | `Integer(value)` — strict; non-numeric strings produce `validation_error` |
|
|
39
|
+
| `Float` | `Float(value)` — strict |
|
|
40
|
+
| `BigDecimal` | `BigDecimal(value.to_s)` |
|
|
41
|
+
| `:boolean` | `"true"/"1"/true → true`, `"false"/"0"/false → false`; other values error |
|
|
42
|
+
| `Date` | `Date.parse(value.to_s)` |
|
|
43
|
+
| `DateTime` | `DateTime.parse(value.to_s)` |
|
|
44
|
+
| `Time` | `Time.parse(value.to_s)` |
|
|
45
|
+
| `Symbol` | `value.to_sym` |
|
|
46
|
+
| `Array` | `Array(value)` — wraps scalars |
|
|
47
|
+
| `Hash` | passthrough; non-hash values produce `validation_error` |
|
|
48
|
+
|
|
49
|
+
- **`Railsmith::BaseService::InputResolver`** — single-pass pipeline that runs on every `call` when inputs are declared:
|
|
50
|
+
1. Apply defaults for missing keys
|
|
51
|
+
2. Coerce types
|
|
52
|
+
3. Validate required fields
|
|
53
|
+
4. Validate `in:` constraints
|
|
54
|
+
5. Apply `transform:` procs
|
|
55
|
+
6. Filter undeclared keys (security: prevents mass-assignment of unexpected fields)
|
|
56
|
+
|
|
57
|
+
- **`filter_inputs false`** class-level opt-out — disables undeclared key filtering when needed. Inherited by subclasses.
|
|
58
|
+
|
|
59
|
+
- **`transform:` option on `input`** — optional zero-arg Proc applied after coercion (e.g. `transform: ->(v) { v.strip.downcase }`).
|
|
60
|
+
|
|
61
|
+
- **Custom coercions** — register arbitrary type coercers globally:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Railsmith.configure do |c|
|
|
65
|
+
c.register_coercion(:money, ->(v) { Money.new(v) })
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- **`Configuration#register_coercion` / `#custom_coercions`** — storage and lookup for custom type coercers.
|
|
70
|
+
|
|
71
|
+
- **Input scoping** — when a `model` is declared, inputs describe `params[:attributes]`; for custom (non-model) actions, inputs describe the top-level `params` hash.
|
|
72
|
+
|
|
73
|
+
- **Inheritance** — subclasses inherit all parent inputs and can add or override them independently.
|
|
74
|
+
|
|
75
|
+
### Added — `call!` Variant & Error Enhancements
|
|
76
|
+
|
|
77
|
+
- **`BaseService.call!`** — raising variant of `call`. Identical signature; raises `Railsmith::Failure` instead of returning a failure `Result`. Intended for controller contexts that use `rescue_from`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Raises on any failure result; returns the Result on success.
|
|
81
|
+
UserService.call!(action: :create, params: params, context: ctx)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **`Railsmith::Failure`** — `StandardError` subclass wrapping a failure `Result`. Carries the original structured error so `rescue` / `rescue_from` handlers can inspect it without parsing a string:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
rescue Railsmith::Failure => e
|
|
88
|
+
e.result # => Railsmith::Result (failure)
|
|
89
|
+
e.code # => "validation_error"
|
|
90
|
+
e.error # => Railsmith::Errors::ErrorPayload
|
|
91
|
+
e.meta # => {}
|
|
92
|
+
e.message # => human-readable error message from the payload
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **`Railsmith::ControllerHelpers`** — `ActiveSupport::Concern` for Rails controllers. Include it once in `ApplicationController` to get automatic JSON error responses mapped to standard HTTP statuses:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class ApplicationController < ActionController::API
|
|
100
|
+
include Railsmith::ControllerHelpers
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Status mapping:
|
|
105
|
+
|
|
106
|
+
| Error code | HTTP status |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `validation_error` | 422 Unprocessable Entity |
|
|
109
|
+
| `not_found` | 404 Not Found |
|
|
110
|
+
| `conflict` | 409 Conflict |
|
|
111
|
+
| `unauthorized` | 401 Unauthorized |
|
|
112
|
+
| `unexpected` | 500 Internal Server Error |
|
|
113
|
+
| _(unknown)_ | 500 Internal Server Error |
|
|
114
|
+
|
|
115
|
+
The rendered JSON body is `result.to_h` — same shape as every other Railsmith failure response.
|
|
116
|
+
|
|
117
|
+
### Added — Association Support
|
|
118
|
+
|
|
119
|
+
- **`has_many` / `has_one` / `belongs_to` DSL** — declare associations directly on a service class:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class OrderService < Railsmith::BaseService
|
|
123
|
+
model Order
|
|
124
|
+
domain :commerce
|
|
125
|
+
|
|
126
|
+
has_many :line_items, service: LineItemService, dependent: :destroy
|
|
127
|
+
has_one :shipping_address, service: AddressService, dependent: :nullify
|
|
128
|
+
belongs_to :customer, service: CustomerService, optional: true
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- **`Railsmith::BaseService::AssociationDefinition`** — frozen value object per association, storing `name`, `kind` (`:has_many`, `:has_one`, `:belongs_to`), `service_class`, `foreign_key`, `dependent`, `optional`, and `validate`.
|
|
133
|
+
|
|
134
|
+
- **`Railsmith::BaseService::AssociationRegistry`** — ordered collection of `AssociationDefinition`s; deep-duped on inheritance so subclasses extend associations without affecting parents.
|
|
135
|
+
|
|
136
|
+
- **`Railsmith::BaseService::AssociationDsl`** — provides the `has_many`, `has_one`, and `belongs_to` class macros. Foreign keys are auto-inferred when not given:
|
|
137
|
+
- `has_many` / `has_one`: FK is `"#{parent_model_name.underscore}_id"` on the child (e.g. `order_id`)
|
|
138
|
+
- `belongs_to`: FK is `"#{association_name}_id"` on this record (e.g. `customer_id`)
|
|
139
|
+
|
|
140
|
+
- **`includes` DSL** (`Railsmith::BaseService::EagerLoading`) — declare eager loads at the class level; multiple calls are additive:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class OrderService < Railsmith::BaseService
|
|
144
|
+
model Order
|
|
145
|
+
|
|
146
|
+
includes :line_items, :customer
|
|
147
|
+
includes line_items: [:product, :variant]
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Declared loads are applied automatically in `find` and `list` (via the `base_scope` helper). Custom action overrides are unaffected.
|
|
152
|
+
|
|
153
|
+
- **`Railsmith::BaseService::NestedWriter`** — handles nested association writes within the parent's open transaction:
|
|
154
|
+
|
|
155
|
+
- **Nested create** — pass nested records under the association key in `params`; the FK is injected automatically:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
OrderService.call(
|
|
159
|
+
action: :create,
|
|
160
|
+
params: {
|
|
161
|
+
attributes: { total: 99.99, customer_id: 7 },
|
|
162
|
+
line_items: [
|
|
163
|
+
{ attributes: { product_id: 1, qty: 2, price: 29.99 } },
|
|
164
|
+
{ attributes: { product_id: 5, qty: 1, price: 39.99 } }
|
|
165
|
+
],
|
|
166
|
+
shipping_address: { attributes: { street: "123 Main St", city: "Portland" } }
|
|
167
|
+
},
|
|
168
|
+
context: ctx
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **Nested update** — per-item semantics driven by the presence of `id` and `_destroy`:
|
|
173
|
+
|
|
174
|
+
| Item shape | Action |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `{ id:, attributes: }` | update via child service |
|
|
177
|
+
| `{ attributes: }` (no `id`) | create via child service (FK injected) |
|
|
178
|
+
| `{ id:, _destroy: true }` | destroy via child service |
|
|
179
|
+
|
|
180
|
+
- **Cascading destroy** — controlled by the `dependent:` option on the association:
|
|
181
|
+
|
|
182
|
+
| Option | Behaviour |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `:destroy` | calls child service `destroy` for each associated record |
|
|
185
|
+
| `:nullify` | calls child service `update` with FK set to `nil` |
|
|
186
|
+
| `:restrict` | returns `validation_error` failure if any children exist |
|
|
187
|
+
| `:ignore` | does nothing (default; relies on DB-level constraints) |
|
|
188
|
+
|
|
189
|
+
All nested operations run within the parent's transaction — any failure triggers a full rollback of both parent and nested writes.
|
|
190
|
+
|
|
191
|
+
- **Association-aware `bulk_create`** — bulk items now support the nested format alongside the existing flat format:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Flat (existing, unchanged)
|
|
195
|
+
items: [{ name: "A" }, { name: "B" }]
|
|
196
|
+
|
|
197
|
+
# Nested (new)
|
|
198
|
+
items: [
|
|
199
|
+
{ attributes: { total: 50.00 }, line_items: [{ attributes: { product_id: 1, qty: 1 } }] },
|
|
200
|
+
{ attributes: { total: 75.00 }, line_items: [{ attributes: { product_id: 2, qty: 1 } }] }
|
|
201
|
+
]
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
When no associations are declared the bulk format and behavior are identical to 1.1.0.
|
|
205
|
+
|
|
206
|
+
### Added — Generator Updates
|
|
207
|
+
|
|
208
|
+
- **`--inputs` flag on `railsmith:model_service`** — generates `input` DSL declarations in the scaffolded service. Two modes:
|
|
209
|
+
|
|
210
|
+
- **Auto-introspect** (`--inputs` with no values): reads `Model.columns_hash` and emits one `input` declaration per non-system column (`id`, `created_at`, `updated_at` are excluded). Requires the model to be loaded at generation time; prints a warning and skips gracefully when it isn't.
|
|
211
|
+
- **Explicit** (`--inputs=email:string:required name:string age:integer`): generates the listed inputs without touching the model. Format per spec: `name:type[:required]`.
|
|
212
|
+
|
|
213
|
+
Supported type tokens and their mapped Ruby types:
|
|
214
|
+
|
|
215
|
+
| Token | Ruby type |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `string`, `text` | `String` |
|
|
218
|
+
| `integer`, `bigint` | `Integer` |
|
|
219
|
+
| `float` | `Float` |
|
|
220
|
+
| `decimal` | `BigDecimal` |
|
|
221
|
+
| `boolean` | `:boolean` |
|
|
222
|
+
| `date` | `Date` |
|
|
223
|
+
| `datetime`, `timestamp` | `DateTime` |
|
|
224
|
+
| `time` | `Time` |
|
|
225
|
+
| `json`, `jsonb`, `hstore` | `Hash` |
|
|
226
|
+
| _(unknown)_ | `String` |
|
|
227
|
+
|
|
228
|
+
- **`--associations` flag on `railsmith:model_service`** — introspects `Model.reflect_on_all_associations` and emits `has_many`, `has_one`, and `belongs_to` declarations plus an `includes` line covering all associations. Prints a warning and skips when the model can't be loaded. Adds `# TODO: Define XxxService` comments for associated service classes that are not yet defined.
|
|
229
|
+
|
|
230
|
+
- **Updated `model_service.rb.tt` template** — renders optional `# -- Inputs --` and `# -- Associations --` sections when the respective flags are used. Sections are omitted entirely when the flags are absent, preserving the existing output for services generated without them.
|
|
231
|
+
|
|
232
|
+
### Deprecated
|
|
233
|
+
|
|
234
|
+
- `required_keys:` keyword on `validate()` — emits a deprecation warning at runtime. Migrate to the `input` DSL with `required: true`. The parameter continues to work for services that do not use the `input` DSL.
|
|
235
|
+
|
|
236
|
+
### Fixed
|
|
237
|
+
|
|
238
|
+
- Architecture checker `MissingServiceUsageChecker` recognizes flat domain operation calls (e.g. `Billing::Invoices::Create.call`) without an `Operations::` segment, matching the 1.1.0 generator defaults.
|
|
239
|
+
- `ArchReport` text footer and JSON summary reflect fail-on vs warn-only mode (`fail_on_arch_violations`).
|
|
240
|
+
|
|
241
|
+
### Changed
|
|
242
|
+
|
|
243
|
+
- `railsmith:install` creates `app/services` only (no empty `app/services/operations/`).
|
|
244
|
+
- Cross-domain warning payloads include `log_json_line` and `log_kv_line` from `CrossDomainWarningFormatter`.
|
|
11
245
|
|
|
12
246
|
---
|
|
13
247
|
|
|
@@ -117,6 +351,6 @@ First stable release. Public DSL and result contract are now frozen.
|
|
|
117
351
|
|
|
118
352
|
Internal bootstrap release. Gem skeleton, CI baseline, and initial service scaffolding. Not intended for production use.
|
|
119
353
|
|
|
120
|
-
[
|
|
354
|
+
[1.2.0]: https://github.com/samaswin/railsmith/compare/v1.1.0...v1.2.0
|
|
121
355
|
[1.1.0]: https://github.com/samaswin/railsmith/compare/v1.0.0...v1.1.0
|
|
122
356
|
[1.0.0]: https://github.com/samaswin/railsmith/releases/tag/v1.0.0
|
data/MIGRATION.md
CHANGED
|
@@ -1,5 +1,245 @@
|
|
|
1
1
|
# Migration Guide
|
|
2
2
|
|
|
3
|
+
## Upgrading from 1.1.0 to 1.2.0
|
|
4
|
+
|
|
5
|
+
All changes in 1.2.0 are **additive and backward-compatible**. Every service written for 1.1.0 continues to work without modification.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### Input DSL (additive, replaces `required_keys:`)
|
|
10
|
+
|
|
11
|
+
The `input` class macro declares expected parameters with types, defaults, and constraints. It is entirely opt-in — services without `input` declarations behave identically to 1.1.0.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class UserService < Railsmith::BaseService
|
|
15
|
+
model User
|
|
16
|
+
domain :identity
|
|
17
|
+
|
|
18
|
+
input :email, String, required: true
|
|
19
|
+
input :age, Integer, default: nil
|
|
20
|
+
input :role, String, in: %w[admin member guest], default: "member"
|
|
21
|
+
input :active, :boolean, default: true
|
|
22
|
+
input :metadata, Hash, default: -> { {} }
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
When inputs are declared:
|
|
27
|
+
|
|
28
|
+
- Types are coerced automatically (string `"42"` → integer `42`, etc.)
|
|
29
|
+
- Required fields that are missing or `nil` return a `validation_error` result
|
|
30
|
+
- Only declared keys are forwarded to the action (undeclared keys are silently dropped)
|
|
31
|
+
- Defaults are applied before the action runs
|
|
32
|
+
|
|
33
|
+
**`required_keys:` is deprecated.** If you currently use `validate(params, required_keys: [:email])`, migrate to `input :email, String, required: true`. The `required_keys:` keyword continues to work but emits a deprecation warning. It will be removed in a future major release.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Before (deprecated, still works with warning)
|
|
37
|
+
def create
|
|
38
|
+
val = validate(params, required_keys: [:email, :name])
|
|
39
|
+
return val if val.failure?
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# After
|
|
44
|
+
input :email, String, required: true
|
|
45
|
+
input :name, String, required: true
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**No migration required.** Existing services are unaffected.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### `call!` and `ControllerHelpers` (additive)
|
|
53
|
+
|
|
54
|
+
`BaseService.call!` is a new class method with the same signature as `call`. It raises `Railsmith::Failure` instead of returning a failure result, for use in controllers that prefer `rescue_from`.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# Raises Railsmith::Failure on any failure; returns Result on success
|
|
58
|
+
UserService.call!(action: :create, params: { attributes: user_params }, context: ctx)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`Railsmith::ControllerHelpers` is a new `ActiveSupport::Concern` for Rails controllers. Include it once in `ApplicationController` to handle all `Railsmith::Failure` exceptions with standard JSON responses and HTTP status codes:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class ApplicationController < ActionController::API
|
|
65
|
+
include Railsmith::ControllerHelpers
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Error code | HTTP status |
|
|
70
|
+
|------------|-------------|
|
|
71
|
+
| `validation_error` | 422 Unprocessable Entity |
|
|
72
|
+
| `not_found` | 404 Not Found |
|
|
73
|
+
| `conflict` | 409 Conflict |
|
|
74
|
+
| `unauthorized` | 401 Unauthorized |
|
|
75
|
+
| `unexpected` | 500 Internal Server Error |
|
|
76
|
+
|
|
77
|
+
Both `call!` and `ControllerHelpers` are entirely opt-in. All existing `call` usage is unaffected.
|
|
78
|
+
|
|
79
|
+
See [docs/call-bang.md](docs/call-bang.md) for detailed usage.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### Association DSL (additive)
|
|
84
|
+
|
|
85
|
+
`has_many`, `has_one`, and `belongs_to` are new class-level macros for declaring associations on a service. They are entirely opt-in — services without association declarations behave identically to 1.1.0.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class OrderService < Railsmith::BaseService
|
|
89
|
+
model Order
|
|
90
|
+
domain :commerce
|
|
91
|
+
|
|
92
|
+
has_many :line_items, service: LineItemService, dependent: :destroy
|
|
93
|
+
has_one :shipping_address, service: AddressService, dependent: :nullify
|
|
94
|
+
belongs_to :customer, service: CustomerService, optional: true
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Options for `has_many` and `has_one`:**
|
|
99
|
+
|
|
100
|
+
| Option | Type | Default | Description |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `service:` | Class | required | service class for the associated records |
|
|
103
|
+
| `foreign_key:` | Symbol | inferred | FK column on the child; inferred as `#{parent_model}_id` |
|
|
104
|
+
| `dependent:` | Symbol | `:ignore` | cascade behaviour on parent destroy (see Cascading Destroy below) |
|
|
105
|
+
| `validate:` | Boolean | `true` | validate nested records |
|
|
106
|
+
|
|
107
|
+
**Options for `belongs_to`:**
|
|
108
|
+
|
|
109
|
+
| Option | Type | Default | Description |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| `service:` | Class | required | service class for the parent record |
|
|
112
|
+
| `foreign_key:` | Symbol | inferred | FK column on this record; inferred as `#{association_name}_id` |
|
|
113
|
+
| `optional:` | Boolean | `false` | skip presence validation |
|
|
114
|
+
|
|
115
|
+
**No migration required.** Existing services are unaffected.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### Eager Loading DSL (additive)
|
|
120
|
+
|
|
121
|
+
The `includes` class macro declares eager loads applied automatically to `find` and `list`. Multiple calls are additive.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class OrderService < Railsmith::BaseService
|
|
125
|
+
model Order
|
|
126
|
+
|
|
127
|
+
includes :line_items, :customer
|
|
128
|
+
includes line_items: [:product, :variant] # merged with the call above
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Before adding `includes`, if you had a custom `find_record` override or a `list` override that applied its own `model_class.includes(...)`, those overrides are **unaffected** — the default `base_scope` applies only to the built-in `find` and `list` actions.
|
|
133
|
+
|
|
134
|
+
If your custom action already calls `find_record(model_klass, id)` it will now benefit from declared eager loads automatically. If this is unwanted, keep calling `model_klass.find_by(id:)` directly.
|
|
135
|
+
|
|
136
|
+
**No migration required.** Opt-in at your own pace.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### Nested Writes (additive)
|
|
141
|
+
|
|
142
|
+
When associations are declared, the `create` and `update` actions accept nested records under the association key in `params`. No change is required in services that do not pass nested params — the guards check `params` for association keys and skip silently when none are present.
|
|
143
|
+
|
|
144
|
+
#### Nested create
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
OrderService.call(
|
|
148
|
+
action: :create,
|
|
149
|
+
params: {
|
|
150
|
+
attributes: { total: 99.99, customer_id: 7 },
|
|
151
|
+
line_items: [
|
|
152
|
+
{ attributes: { product_id: 1, qty: 2, price: 29.99 } },
|
|
153
|
+
{ attributes: { product_id: 5, qty: 1, price: 39.99 } }
|
|
154
|
+
],
|
|
155
|
+
shipping_address: { attributes: { street: "123 Main St", city: "Portland" } }
|
|
156
|
+
},
|
|
157
|
+
context: ctx
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The parent FK (`order_id`) is injected into each child's attributes automatically — you do not pass it.
|
|
162
|
+
|
|
163
|
+
All child writes run inside the parent's open transaction. Any failure rolls back the entire operation including the parent record.
|
|
164
|
+
|
|
165
|
+
#### Nested update
|
|
166
|
+
|
|
167
|
+
Pass nested items under the association key in `update` params. Per-item semantics:
|
|
168
|
+
|
|
169
|
+
| Item shape | Action taken |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `{ id:, attributes: }` | update the existing child record |
|
|
172
|
+
| `{ attributes: }` (no `id`) | create a new child record (FK injected) |
|
|
173
|
+
| `{ id:, _destroy: true }` | destroy the child record |
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
OrderService.call(
|
|
177
|
+
action: :update,
|
|
178
|
+
params: {
|
|
179
|
+
id: 42,
|
|
180
|
+
attributes: { total: 109.99 },
|
|
181
|
+
line_items: [
|
|
182
|
+
{ id: 1, attributes: { qty: 3 } }, # update
|
|
183
|
+
{ attributes: { product_id: 9, qty: 1 } }, # create
|
|
184
|
+
{ id: 2, _destroy: true } # destroy
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
context: ctx
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**No migration required.** Existing `update` calls without nested keys work exactly as before.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
### Cascading Destroy (additive)
|
|
196
|
+
|
|
197
|
+
When `has_many` or `has_one` is declared with a `dependent:` option other than `:ignore`, the `destroy` action handles associated records through their service before deleting the parent.
|
|
198
|
+
|
|
199
|
+
| `dependent:` | Behaviour |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `:destroy` | calls child service `destroy` for each associated record |
|
|
202
|
+
| `:nullify` | calls child service `update` with FK set to `nil` |
|
|
203
|
+
| `:restrict` | returns `validation_error` failure if any children exist (parent is not deleted) |
|
|
204
|
+
| `:ignore` | does nothing — default, matches 1.1.0 behaviour |
|
|
205
|
+
|
|
206
|
+
The default is `:ignore` so no existing `destroy` call changes behaviour unless you explicitly add a `dependent:` option to an association.
|
|
207
|
+
|
|
208
|
+
All cascading operations run inside the parent's transaction. Any failure rolls back the entire destroy.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
### `bulk_create` — extended item format (backward-compatible)
|
|
213
|
+
|
|
214
|
+
`bulk_create` now accepts items in either the existing flat format or a new nested format when associations are declared.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# Flat format — unchanged, still works exactly as before
|
|
218
|
+
items: [{ name: "A" }, { name: "B" }]
|
|
219
|
+
|
|
220
|
+
# Nested format — new, used when associations are declared
|
|
221
|
+
items: [
|
|
222
|
+
{ attributes: { total: 50.00 }, line_items: [{ attributes: { product_id: 1, qty: 1 } }] },
|
|
223
|
+
{ attributes: { total: 75.00 }, line_items: [{ attributes: { product_id: 2, qty: 1 } }] }
|
|
224
|
+
]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The two formats are detected automatically by the presence of an `attributes:` key in the item hash. Existing bulk calls using the flat format continue to work without any change.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
### Upgrade steps for 1.1.0 → 1.2.0
|
|
232
|
+
|
|
233
|
+
1. Update `Gemfile`: `gem "railsmith", "~> 1.2"`
|
|
234
|
+
2. Run `bundle install`.
|
|
235
|
+
3. Run `bundle exec rspec` — all existing specs should pass with zero changes.
|
|
236
|
+
4. Opt-in to the `input` DSL on services where you want type coercion and validation (Phase 1, already available since the unreleased branch).
|
|
237
|
+
5. Opt-in to `has_many` / `has_one` / `belongs_to` on services that need nested writes or cascading destroy.
|
|
238
|
+
6. Opt-in to `includes` on services that need eager loading on `find` and `list`.
|
|
239
|
+
7. Deploy.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
3
243
|
## Upgrading from 1.0.0 to 1.1.0
|
|
4
244
|
|
|
5
245
|
### Ruby >= 3.1 (non-breaking)
|
|
@@ -363,7 +603,7 @@ Add any missing keys to `config/initializers/railsmith.rb`. All keys have safe d
|
|
|
363
603
|
```ruby
|
|
364
604
|
Railsmith.configure do |config|
|
|
365
605
|
config.warn_on_cross_domain_calls = true # default: true
|
|
366
|
-
config.strict_mode = false # default: false
|
|
606
|
+
config.strict_mode = false # default: false; when true, +on_cross_domain_violation+ runs on each cross-domain call
|
|
367
607
|
config.fail_on_arch_violations = false # default: false
|
|
368
608
|
config.cross_domain_allowlist = [] # default: []
|
|
369
609
|
config.on_cross_domain_violation = nil # default: nil (no-op)
|
data/README.md
CHANGED
|
@@ -72,6 +72,101 @@ result.error.to_h # => { code: "not_found", message: "User not found", deta
|
|
|
72
72
|
|
|
73
73
|
---
|
|
74
74
|
|
|
75
|
+
## Declarative Inputs
|
|
76
|
+
|
|
77
|
+
Declare expected parameters with types, defaults, and constraints using the `input` DSL. Railsmith coerces, validates, and filters params automatically before the action runs.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class UserService < Railsmith::BaseService
|
|
81
|
+
model User
|
|
82
|
+
domain :identity
|
|
83
|
+
|
|
84
|
+
input :email, String, required: true, transform: ->(v) { v.strip.downcase }
|
|
85
|
+
input :age, Integer, default: nil
|
|
86
|
+
input :role, String, in: %w[admin member guest], default: "member"
|
|
87
|
+
input :active, :boolean, default: true
|
|
88
|
+
input :metadata, Hash, default: -> { {} }
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **Type coercion** — strings to integers, booleans, dates, and more
|
|
93
|
+
- **Validation** — required fields, allowed value lists, coercion failures all return structured `validation_error` results
|
|
94
|
+
- **Input filtering** — only declared keys reach the action (mass-assignment protection)
|
|
95
|
+
- **Inheritance** — subclasses inherit parent inputs and can extend or override independently
|
|
96
|
+
|
|
97
|
+
See [docs/inputs.md](docs/inputs.md) for the full reference.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Association Support
|
|
102
|
+
|
|
103
|
+
Declare associations at the service level for eager loading, nested CRUD, and cascading destroy.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class OrderService < Railsmith::BaseService
|
|
107
|
+
model Order
|
|
108
|
+
domain :commerce
|
|
109
|
+
|
|
110
|
+
has_many :line_items, service: LineItemService, dependent: :destroy
|
|
111
|
+
has_one :shipping_address, service: AddressService, dependent: :nullify
|
|
112
|
+
belongs_to :customer, service: CustomerService, optional: true
|
|
113
|
+
|
|
114
|
+
includes :line_items, :customer
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Nested create** — pass associated records in `params`; the foreign key is injected automatically:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
OrderService.call(
|
|
122
|
+
action: :create,
|
|
123
|
+
params: {
|
|
124
|
+
attributes: { total: 99.99, customer_id: 7 },
|
|
125
|
+
line_items: [
|
|
126
|
+
{ attributes: { product_id: 1, qty: 2, price: 29.99 } }
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
context: ctx
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
All nested writes run in the parent's transaction. Any failure rolls back everything.
|
|
134
|
+
|
|
135
|
+
See [docs/associations.md](docs/associations.md) for the full reference.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## `call!` — Raising Variant
|
|
140
|
+
|
|
141
|
+
`call!` raises `Railsmith::Failure` instead of returning a failure result. Use it in controllers with `rescue_from`:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class ApplicationController < ActionController::API
|
|
145
|
+
include Railsmith::ControllerHelpers
|
|
146
|
+
# Catches Railsmith::Failure and renders JSON with the correct HTTP status
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class UsersController < ApplicationController
|
|
150
|
+
def create
|
|
151
|
+
result = UserService.call!(action: :create, params: { attributes: user_params }, context: ctx)
|
|
152
|
+
render json: result.value, status: :created
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`Railsmith::Failure` carries the full structured result for inspection in rescue handlers:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
rescue Railsmith::Failure => e
|
|
161
|
+
e.code # => "validation_error"
|
|
162
|
+
e.result # => Railsmith::Result (failure)
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
See [docs/call-bang.md](docs/call-bang.md) for the full reference.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
75
170
|
## Generators
|
|
76
171
|
|
|
77
172
|
| Command | Output |
|
|
@@ -79,6 +174,8 @@ result.error.to_h # => { code: "not_found", message: "User not found", deta
|
|
|
79
174
|
| `rails g railsmith:install` | Initializer + service directories |
|
|
80
175
|
| `rails g railsmith:domain Billing` | `app/domains/billing.rb` + subdirectories |
|
|
81
176
|
| `rails g railsmith:model_service User` | `app/services/user_service.rb` |
|
|
177
|
+
| `rails g railsmith:model_service User --inputs` | Service with `input` DSL (introspects model columns) |
|
|
178
|
+
| `rails g railsmith:model_service Order --associations` | Service with association DSL (introspects model associations) |
|
|
82
179
|
| `rails g railsmith:model_service Billing::Invoice --domain=Billing` | `app/domains/billing/services/invoice_service.rb` |
|
|
83
180
|
| `rails g railsmith:operation Billing::Invoices::Create` | `app/domains/billing/invoices/create.rb` |
|
|
84
181
|
|
|
@@ -164,7 +261,7 @@ ctx = Railsmith::Context.new(domain: :billing, request_id: "req-abc")
|
|
|
164
261
|
Billing::Services::InvoiceService.call(action: :create, params: { ... }, context: ctx)
|
|
165
262
|
```
|
|
166
263
|
|
|
167
|
-
When the context domain differs from a service's declared `domain`, Railsmith emits a `cross_domain.warning.railsmith` instrumentation event.
|
|
264
|
+
When the context domain differs from a service's declared `domain`, Railsmith emits a `cross_domain.warning.railsmith` instrumentation event. The payload includes `log_json_line` and `log_kv_line` (from `Railsmith::CrossDomainWarningFormatter`) for structured logging; when `strict_mode` is true, `on_cross_domain_violation` receives the same payload.
|
|
168
265
|
|
|
169
266
|
Configure enforcement in `config/initializers/railsmith.rb`:
|
|
170
267
|
|
|
@@ -214,9 +311,12 @@ See [Migration](MIGRATION.md#embedding-architecture-checks-from-ruby) for option
|
|
|
214
311
|
## Documentation
|
|
215
312
|
|
|
216
313
|
- [Quickstart](docs/quickstart.md) — install, generate, first call
|
|
217
|
-
- [
|
|
314
|
+
- [Inputs](docs/inputs.md) — declarative input DSL, type coercion, filtering, custom coercions
|
|
315
|
+
- [Associations](docs/associations.md) — association DSL, eager loading, nested CRUD, cascading destroy
|
|
316
|
+
- [call!](docs/call-bang.md) — raising variant, controller integration, `ControllerHelpers`
|
|
317
|
+
- [Cookbook](docs/cookbook.md) — CRUD, bulk, inputs, associations, domain context, error mapping, observability
|
|
218
318
|
- [Legacy Adoption Guide](docs/legacy-adoption.md) — incremental migration strategy
|
|
219
|
-
- [Migration](MIGRATION.md) — upgrading from 1.0.x to 1.
|
|
319
|
+
- [Migration](MIGRATION.md) — upgrading from 1.0.x to 1.2.x (and earlier releases)
|
|
220
320
|
- [Changelog](CHANGELOG.md)
|
|
221
321
|
|
|
222
322
|
---
|