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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +236 -2
  3. data/MIGRATION.md +241 -1
  4. data/README.md +103 -3
  5. data/docs/associations.md +206 -0
  6. data/docs/call-bang.md +123 -0
  7. data/docs/cookbook.md +277 -6
  8. data/docs/inputs.md +212 -0
  9. data/lib/generators/railsmith/install/install_generator.rb +0 -1
  10. data/lib/generators/railsmith/model_service/model_service_generator.rb +124 -14
  11. data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +21 -0
  12. data/lib/railsmith/arch_checks/cli.rb +9 -1
  13. data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +16 -4
  14. data/lib/railsmith/arch_report.rb +16 -5
  15. data/lib/railsmith/base_service/association_definition.rb +59 -0
  16. data/lib/railsmith/base_service/association_dsl.rb +92 -0
  17. data/lib/railsmith/base_service/association_registry.rb +46 -0
  18. data/lib/railsmith/base_service/bulk_actions.rb +11 -3
  19. data/lib/railsmith/base_service/bulk_params.rb +8 -0
  20. data/lib/railsmith/base_service/crud_actions.rb +40 -18
  21. data/lib/railsmith/base_service/crud_record_helpers.rb +1 -1
  22. data/lib/railsmith/base_service/eager_loading.rb +64 -0
  23. data/lib/railsmith/base_service/input_definition.rb +37 -0
  24. data/lib/railsmith/base_service/input_dsl.rb +117 -0
  25. data/lib/railsmith/base_service/input_registry.rb +46 -0
  26. data/lib/railsmith/base_service/input_resolver.rb +159 -0
  27. data/lib/railsmith/base_service/nested_writer/cascading_destroy.rb +80 -0
  28. data/lib/railsmith/base_service/nested_writer/nested_write/write_nested.rb +147 -0
  29. data/lib/railsmith/base_service/nested_writer/nested_write/write_nested_item.rb +60 -0
  30. data/lib/railsmith/base_service/nested_writer/nested_write.rb +16 -0
  31. data/lib/railsmith/base_service/nested_writer.rb +48 -0
  32. data/lib/railsmith/base_service/type_coercion.rb +117 -0
  33. data/lib/railsmith/base_service/validation.rb +9 -0
  34. data/lib/railsmith/base_service.rb +40 -12
  35. data/lib/railsmith/configuration.rb +16 -0
  36. data/lib/railsmith/controller_helpers.rb +42 -0
  37. data/lib/railsmith/cross_domain_guard.rb +11 -1
  38. data/lib/railsmith/failure.rb +27 -0
  39. data/lib/railsmith/version.rb +1 -1
  40. data/lib/railsmith.rb +2 -0
  41. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a20ec84a09c4074cd56944e07f4ea7f14ffab15e8f9fe41d4f288ac3cc2f9ce
4
- data.tar.gz: 47e886d32518f52ba779f4ce0727737653b7e719d7b266ca8a2769ab7ff2bb97
3
+ metadata.gz: e9b32b70ceece070f58ca5699fb4caecc906a4b3204f1fb0f898783272d396d9
4
+ data.tar.gz: eb1668ea34874567dec4f7a4fbdf56da93a4fad6268f15552e84ecf3fcc5fcc4
5
5
  SHA512:
6
- metadata.gz: 9c6275d694e51d5bfa1d5dd5f375af5079b0fdd96bb38252f322279df514e3e1e7945840ccf9bba14495b4e55cf7c33a4dfbfefce903402e5fe1c7345cae6984
7
- data.tar.gz: 909725e026330771fd81e0b5fd0077f7f92621dc017cda6b882a7e5f276db8be589edab63857823e1c604af9b58a7a968f90f825b78652022c4a90b6f86c8285
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
- ## [Unreleased]
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
- [Unreleased]: https://github.com/samaswin/railsmith/compare/v1.1.0...HEAD
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 (reserved for v1.2)
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
- - [Cookbook](docs/cookbook.md) — CRUD, bulk, domain context, error mapping, observability
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.1.x (and earlier releases)
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
  ---