well_formed 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +12 -0
  4. data/.standard.yml +3 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +869 -0
  9. data/Rakefile +36 -0
  10. data/Steepfile +17 -0
  11. data/gems/well_formed-pundit/.rspec +3 -0
  12. data/gems/well_formed-pundit/Gemfile +13 -0
  13. data/gems/well_formed-pundit/README.md +73 -0
  14. data/gems/well_formed-pundit/Rakefile +12 -0
  15. data/gems/well_formed-pundit/lib/well_formed/pundit/version.rb +7 -0
  16. data/gems/well_formed-pundit/lib/well_formed/pundit.rb +25 -0
  17. data/gems/well_formed-pundit/lib/well_formed-pundit.rb +8 -0
  18. data/gems/well_formed-pundit/spec/spec_helper.rb +13 -0
  19. data/gems/well_formed-pundit/spec/well_formed/pundit_integration_spec.rb +101 -0
  20. data/gems/well_formed-pundit/spec/well_formed/pundit_spec.rb +80 -0
  21. data/gems/well_formed-pundit/well_formed-pundit.gemspec +28 -0
  22. data/lib/generators/resource_form_generator.rb +26 -0
  23. data/lib/generators/templates/form.rb.tt +28 -0
  24. data/lib/well_formed/action_form.rb +7 -0
  25. data/lib/well_formed/attribute_assignment.rb +42 -0
  26. data/lib/well_formed/collections.rb +114 -0
  27. data/lib/well_formed/errors.rb +16 -0
  28. data/lib/well_formed/initializer.rb +31 -0
  29. data/lib/well_formed/nested_attributes.rb +194 -0
  30. data/lib/well_formed/nested_form.rb +14 -0
  31. data/lib/well_formed/performer.rb +57 -0
  32. data/lib/well_formed/persistence.rb +61 -0
  33. data/lib/well_formed/railtie.rb +9 -0
  34. data/lib/well_formed/record_identity.rb +32 -0
  35. data/lib/well_formed/resource_form.rb +7 -0
  36. data/lib/well_formed/simple_action.rb +14 -0
  37. data/lib/well_formed/simple_nested_form.rb +12 -0
  38. data/lib/well_formed/simple_resource.rb +14 -0
  39. data/lib/well_formed/simple_struct.rb +29 -0
  40. data/lib/well_formed/struct.rb +7 -0
  41. data/lib/well_formed/transactional.rb +38 -0
  42. data/lib/well_formed/translations.rb +30 -0
  43. data/lib/well_formed/version.rb +5 -0
  44. data/lib/well_formed/with_user.rb +43 -0
  45. data/lib/well_formed.rb +38 -0
  46. data/rbs_collection.yaml +19 -0
  47. data/sig/well_formed.rbs +129 -0
  48. metadata +105 -0
data/README.md ADDED
@@ -0,0 +1,869 @@
1
+ # WellFormed
2
+
3
+ WellFormed is a lightweight form object library for Rails. Inherit from `WellFormed::ResourceForm` for standard create/update flows, or `WellFormed::ActionForm` for custom actions that don't persist a resource — both accept a resource, a user, and a params hash, with full `ActiveModel::Model` and `ActiveModel::Attributes` support.
4
+
5
+ Form objects are compatible with `form_with` and Rails view helpers anywhere an ActiveRecord model would be accepted.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```ruby
26
+ class CreateArticleForm < WellFormed::ResourceForm
27
+ resource_alias :article
28
+
29
+ attribute :title, :string
30
+ attribute :body, :string
31
+
32
+ validates :title, presence: true
33
+ validates :body, presence: true
34
+ end
35
+ ```
36
+
37
+ ```ruby
38
+ # app/controllers/articles_controller.rb
39
+ def create
40
+ @form = CreateArticleForm.new(Article.new, current_user, article_params)
41
+ if (@article = @form.submit)
42
+ redirect_to @article
43
+ else
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+ end
47
+ ```
48
+
49
+ ```ruby
50
+ # app/controllers/api/articles_controller.rb
51
+ def create
52
+ @form = CreateArticleForm.new(Article.new, current_user, article_params)
53
+ if @form.save
54
+ render json: @form.article, status: :created
55
+ else
56
+ render json: {errors: @form.errors.messages}, status: :unprocessable_content
57
+ end
58
+ end
59
+ ```
60
+
61
+ ```ruby
62
+ class PublishArticleForm < WellFormed::ActionForm
63
+ resource_alias :article
64
+
65
+ attribute :notify_subscribers, :boolean, default: false
66
+
67
+ def perform
68
+ article.publish!
69
+ PublishMailer.notify(article).deliver_later if notify_subscribers
70
+ end
71
+ end
72
+ ```
73
+
74
+ ```ruby
75
+ # app/controllers/articles_controller.rb
76
+ def publish
77
+ @form = PublishArticleForm.new(@article, current_user, publish_params)
78
+ if @form.submit
79
+ redirect_to @article
80
+ else
81
+ render :edit, status: :unprocessable_entity
82
+ end
83
+ end
84
+ ```
85
+
86
+ ### Constructor
87
+
88
+ ```ruby
89
+ form = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
90
+ ```
91
+
92
+ | Argument | Description |
93
+ |----------|-------------|
94
+ | `resource` | The ActiveRecord object (or any object) the form wraps |
95
+ | `user` | The user performing the action |
96
+ | `params` | A hash of form values to assign to declared attributes (optional, defaults to `{}`) |
97
+
98
+ ```ruby
99
+ form.resource # => article
100
+ form.user # => current_user
101
+ form.title # => "Hello"
102
+
103
+ form.valid? # => true / false (ActiveModel validations)
104
+ form.errors # => ActiveModel::Errors
105
+ ```
106
+
107
+ ### Overriding the constructor
108
+
109
+ Subclasses can define their own `initialize` — just call `super` to let the base set up `resource`, `user`, and attribute assignment. A common pattern is **nested resource creation**: the form accepts the parent as its first argument and builds the child record internally, keeping the controller clean:
110
+
111
+ ```ruby
112
+ module Articles
113
+ class CreatePostForm < WellFormed::ResourceForm
114
+ resource_alias :post
115
+
116
+ attribute :title, :string
117
+ attribute :body, :string
118
+
119
+ validates :title, presence: true
120
+
121
+ def initialize(article, user, params = {})
122
+ super(article.posts.build, user, params)
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ ```ruby
129
+ # Controller
130
+ def create
131
+ @form = Articles::CreatePostForm.new(@article, current_user, post_params)
132
+ if @form.save
133
+ redirect_to @article
134
+ else
135
+ render :new, status: :unprocessable_entity
136
+ end
137
+ end
138
+ ```
139
+
140
+ `article.posts.build` scopes the new record to the parent so the association is set before `save` is called.
141
+
142
+ ### Translations — `resource_alias`
143
+
144
+ Call `resource_alias` to declare what the wrapped resource represents. This does two things:
145
+
146
+ 1. Adds a reader method on the form that aliases `resource` using the given name
147
+ 2. Sets `model_name` so that ActiveModel error messages use the correct I18n translation keys
148
+
149
+ ```ruby
150
+ class CreateArticleForm < WellFormed::ResourceForm
151
+ resource_alias :article
152
+
153
+ attribute :title, :string
154
+ validates :title, presence: true
155
+ end
156
+
157
+ form = CreateArticleForm.new(article, current_user, {})
158
+ form.article # => same as form.resource
159
+ form.errors.full_messages # => ["Title can't be blank"] (keyed under "article.*")
160
+ ```
161
+
162
+ `resource_alias` accepts a symbol, a snake_case or CamelCase string, or an ActiveModel class:
163
+
164
+ ```ruby
165
+ resource_alias :article # symbol
166
+ resource_alias "article_comment" # snake_case string
167
+ resource_alias Article # class — borrows Article.model_name directly
168
+ ```
169
+
170
+ ### Persistence — `save` and `submit`
171
+
172
+ The default `save` method:
173
+
174
+ 1. Runs validations — returns `false` immediately if invalid
175
+ 2. Assigns only form attributes that the resource has a corresponding setter for
176
+ 3. Calls `save` on the resource and returns its result (`true` / `false`)
177
+
178
+ ```ruby
179
+ form = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
180
+ form.save # => true / false
181
+ ```
182
+
183
+ `submit` is a convenience wrapper around `save` that returns the resource on success or `false` on failure, making controller code more concise:
184
+
185
+ ```ruby
186
+ if (article = form.submit)
187
+ render json: article.as_json, status: :created
188
+ else
189
+ render json: { errors: form.errors.messages }, status: :unprocessable_entity
190
+ end
191
+ ```
192
+
193
+ Use `save!` or `submit!` when you prefer raising over checking a return value — both raise `WellFormed::RecordInvalid` on failure:
194
+
195
+ ```ruby
196
+ form.save! # raises WellFormed::RecordInvalid if invalid or resource.save fails
197
+ form.submit! # raises WellFormed::RecordInvalid if invalid or resource.save fails
198
+ # (submit! also returns the resource on success)
199
+
200
+ rescue WellFormed::RecordInvalid => e
201
+ e.record # => the form object
202
+ e.message # => "Validation failed: Title can't be blank"
203
+ e.record.errors # => ActiveModel::Errors
204
+ end
205
+ ```
206
+
207
+ For side effects around the save — sending emails, creating audit logs, etc. — use `before_save`, `after_save`, and `after_save_commit` callbacks. For forms that need fully custom persistence logic, consider `WellFormed::Struct` (plain Ruby object resources) or `WellFormed::ActionForm` (no resource persistence at all).
208
+
209
+ #### Unmatched attributes
210
+
211
+ Form attributes with no matching setter on the resource are silently skipped when `save` is called — they are filtered out before `assign_attributes` is called, so Rails never raises `ActiveModel::UnknownAttributeError`.
212
+
213
+ This is the normal case for virtual attributes like `agree_to_terms` or `current_password`, which exist on the form for validation or logic but have no corresponding column on the model — they are validated and accessible on the form as normal, without being assigned to the resource. Alternatively, declare the attribute on the form with `attr_writer` (or `attr_accessor`) instead of `attribute` — the value is still assigned from params, but won't be forwarded to the resource on save.
214
+
215
+ Use `unmatched_attributes` to opt in to a warning or error at the form level instead:
216
+
217
+ ```ruby
218
+ class CreateArticleForm < WellFormed::ResourceForm
219
+ unmatched_attributes :warn # prints a warning to stderr
220
+ # unmatched_attributes :raise # raises WellFormed::UnmatchedAttributesError
221
+ # unmatched_attributes :ignore # default — silent skip
222
+ end
223
+ ```
224
+
225
+ `:warn` and `:raise` are useful during development to catch typos or attribute drift between the form and its resource.
226
+
227
+ #### Model validation errors — `merge_model_errors`
228
+
229
+ By default, `save` only runs form-level validations. If `resource.save` fails due to a model-level validation that the form does not replicate, `save` returns `false` with a generic `:base` error (`"could not be saved"`) so that `errors` is never silently empty.
230
+
231
+ When you want model errors surfaced on the form — for example in an API that uses `Halitosis::ErrorsSerializer` — declare `merge_model_errors` in the form class. The form will then copy `resource.errors` onto itself whenever `resource.save` returns `false`:
232
+
233
+ ```ruby
234
+ class Api::CreateUserForm < WellFormed::ResourceForm
235
+ resource_alias :user
236
+
237
+ merge_model_errors # copies resource.errors onto the form on save failure
238
+
239
+ attribute :name, :string
240
+ attribute :email, :string
241
+
242
+ validates :name, presence: true
243
+ validates :email, presence: true
244
+ # No format validation here — the User model owns that rule
245
+ end
246
+ ```
247
+
248
+ ```ruby
249
+ # User model
250
+ class User < ApplicationRecord
251
+ validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/, message: "is invalid" }
252
+ end
253
+ ```
254
+
255
+ Submitting `email: "not-an-email"` passes form validation but fails at the model. With `merge_model_errors`, the error is copied back:
256
+
257
+ ```ruby
258
+ form.save # => false
259
+ form.errors[:email] # => ["is invalid"]
260
+ ```
261
+
262
+ This integrates cleanly with Halitosis error serialisation:
263
+
264
+ ```ruby
265
+ render json: Halitosis::ErrorsSerializer.new(@form), status: :unprocessable_content
266
+ # { "errors": [{ "code": "email_invalid", "detail": "Email is invalid",
267
+ # "source": { "pointer": "/user/email" } }] }
268
+ ```
269
+
270
+ #### Callbacks — `before_validation` and `after_validation`
271
+
272
+ Use `before_validation` to normalise or coerce attribute values before the validation run, and `after_validation` to act on the errors that were just collected — or to transform attribute values before they are assigned to the resource.
273
+
274
+ ```ruby
275
+ class CreateArticleForm < WellFormed::ResourceForm
276
+ attribute :title, :string
277
+ attribute :tag_list, :string
278
+
279
+ validates :title, presence: true
280
+
281
+ before_validation :normalise_title
282
+ after_validation :log_errors
283
+
284
+ private
285
+
286
+ def normalise_title
287
+ self.title = title&.strip&.downcase
288
+ end
289
+
290
+ def log_errors
291
+ Rails.logger.warn(errors.full_messages) if errors.any?
292
+ end
293
+ end
294
+ ```
295
+
296
+ Blocks are also accepted:
297
+
298
+ ```ruby
299
+ before_validation { self.title = title&.strip }
300
+ ```
301
+
302
+ To transform a form attribute before it reaches the resource, use `after_validation` — it runs after the validation run and before attributes are assigned:
303
+
304
+ ```ruby
305
+ after_validation { self.title = title&.strip&.downcase }
306
+ ```
307
+
308
+ #### Callbacks — `before_save` and `after_save`
309
+
310
+ Use `before_save` to run logic after attributes have been assigned to the resource but before it is saved. Use `after_save` to run logic once the resource has been saved.
311
+
312
+ ```ruby
313
+ class CreateArticleForm < WellFormed::ResourceForm
314
+ attribute :title, :string
315
+
316
+ before_save :set_created_by
317
+ after_save :notify_subscribers
318
+
319
+ private
320
+
321
+ def set_created_by
322
+ resource.created_by = user
323
+ end
324
+
325
+ def notify_subscribers
326
+ NotificationMailer.new_article(resource).deliver_later
327
+ end
328
+ end
329
+ ```
330
+
331
+ Blocks are also accepted:
332
+
333
+ ```ruby
334
+ before_save { resource.created_by = user }
335
+ ```
336
+
337
+ A `before_save` callback can halt the save by calling `throw :abort`, in which case `save` returns `false` and `resource.save` is never called:
338
+
339
+ ```ruby
340
+ before_save { throw :abort unless user.can_publish? }
341
+ ```
342
+
343
+ #### Commit callbacks — `after_save_commit`
344
+
345
+ `after_save_commit` registers a callback that fires after all surrounding database transactions have committed — safe for side effects like sending emails or enqueuing background jobs that must not run if the transaction rolls back.
346
+
347
+ Internally, `after_save_commit` uses `ActiveRecord.after_all_transactions_commit` — so if `save` is called inside a larger controller-level transaction, the callbacks wait for the outermost transaction to commit before firing.
348
+
349
+ ```ruby
350
+ class CreateArticleForm < WellFormed::ResourceForm
351
+ after_save_commit :notify_subscribers
352
+
353
+ private
354
+
355
+ def notify_subscribers
356
+ NotificationMailer.new_article(resource).deliver_later
357
+ end
358
+ end
359
+ ```
360
+
361
+ #### Transactions — `save_within_transaction`
362
+
363
+ Call `save_within_transaction` to wrap the entire save (including all callbacks) in a database transaction. If `resource.save` returns `false`, or any callback raises, the transaction is rolled back automatically:
364
+
365
+ ```ruby
366
+ class CreateArticleForm < WellFormed::ResourceForm
367
+ resource_alias :article
368
+
369
+ attribute :title, :string
370
+ attribute :body, :string
371
+
372
+ validates :title, presence: true
373
+
374
+ save_within_transaction
375
+
376
+ after_save :create_audit_log # runs inside the transaction
377
+ after_save_commit :notify_subscribers # runs after the transaction commits
378
+
379
+ private
380
+
381
+ def create_audit_log
382
+ AuditLog.create!(action: :created, record: article, user: user)
383
+ end
384
+
385
+ def notify_subscribers
386
+ NotificationMailer.new_article(article).deliver_later
387
+ end
388
+ end
389
+ ```
390
+
391
+ If `AuditLog.create!` raises, the article save is rolled back with it. `after_save_commit` callbacks only fire once the transaction successfully commits.
392
+
393
+ ## Action forms
394
+
395
+ For custom actions that don't map to a standard create/update — publishing, archiving, sending a notification — inherit from `WellFormed::ActionForm`. These are identical to regular forms except that attributes are not assigned to the resource, `save` is never called, and there is no default `save` implementation. You define `perform` instead.
396
+
397
+ ```ruby
398
+ class PublishArticleForm < WellFormed::ActionForm
399
+ resource_alias :article
400
+
401
+ attribute :notify_subscribers, :boolean, default: false
402
+
403
+ validates :article, presence: true
404
+
405
+ def perform
406
+ article.publish!
407
+ PublishMailer.notify(article).deliver_later if notify_subscribers
408
+ end
409
+ end
410
+ ```
411
+
412
+ Call `submit` to run validations and then `perform`, or `submit!` to raise on failure:
413
+
414
+ ```ruby
415
+ form = PublishArticleForm.new(article, current_user, { notify_subscribers: true })
416
+ form.submit # => true if perform ran, false if invalid or halted by before_perform
417
+ # if halted with no errors, a generic base error ("could not be performed") is added
418
+ form.submit! # raises WellFormed::RecordInvalid if invalid or before_perform halts
419
+ ```
420
+
421
+ ### Callbacks — `before_perform` and `after_perform`
422
+
423
+ `before_perform` and `after_perform` work the same way as `before_save`/`after_save` in regular forms:
424
+
425
+ ```ruby
426
+ class PublishArticleForm < WellFormed::ActionForm
427
+ before_perform :check_permissions
428
+ after_perform :audit_log
429
+
430
+ def perform
431
+ article.publish!
432
+ end
433
+
434
+ def check_permissions
435
+ throw :abort unless user.can_publish?(article)
436
+ end
437
+
438
+ def audit_log
439
+ AuditLog.record(user, :published, article)
440
+ end
441
+ end
442
+ ```
443
+
444
+ Blocks are also accepted:
445
+
446
+ ```ruby
447
+ before_perform { throw :abort unless user.can_publish?(article) }
448
+ ```
449
+
450
+ ## Nested attributes
451
+
452
+ Nested form objects are built in to `WellFormed::ResourceForm`, `WellFormed::ActionForm`, and `WellFormed::Struct`.
453
+
454
+ ```ruby
455
+ class CreateOrderForm < WellFormed::ResourceForm
456
+ resource_alias :order
457
+
458
+ attribute :customer_name, :string
459
+ validates :customer_name, presence: true
460
+
461
+ nested_attributes_for :line_items do
462
+ attribute :name, :string
463
+ attribute :quantity, :integer
464
+
465
+ validates :name, presence: true
466
+ validates :quantity, numericality: { greater_than: 0 }
467
+ end
468
+
469
+ nested_attribute_for :billing_address do
470
+ attribute :street, :string
471
+ attribute :city, :string
472
+ attribute :postcode, :string
473
+
474
+ validates :street, presence: true
475
+ validates :city, presence: true
476
+ validates :postcode, presence: true
477
+ end
478
+ end
479
+ ```
480
+
481
+ ### Macros
482
+
483
+ | Macro | Wraps |
484
+ |-------|-------|
485
+ | `nested_attributes_for :name` | A **collection** of nested forms (e.g. `has_many`) |
486
+ | `nested_attribute_for :name` | A **single** nested form (e.g. `has_one` / `belongs_to`) |
487
+
488
+ Each macro accepts either an inline block (shown above) or an explicit form class:
489
+
490
+ ```ruby
491
+ nested_attributes_for :line_items, LineItemForm
492
+ nested_attribute_for :billing_address, BillingAddressForm
493
+ ```
494
+
495
+ Passing both raises `ArgumentError`.
496
+
497
+ ### Setters
498
+
499
+ Each macro defines two setters — both do the same thing and can be used interchangeably:
500
+
501
+ | Setter | Use case |
502
+ |--------|----------|
503
+ | `name_attributes=` | Rails `fields_for` / `simple_fields_for` (HTML form params with `_attributes` suffix) |
504
+ | `name=` | API-style params without the suffix |
505
+
506
+ The underlying resource receives `name_attributes=` immediately when either setter is called, so `accepts_nested_attributes_for` on the model works as normal.
507
+
508
+ ### Validations and errors
509
+
510
+ Nested forms are validated automatically as part of the parent form's `valid?` call. Errors from nested forms are promoted to the parent using structured keys:
511
+
512
+ - **Collection** — `"line_items[0].name"`, `"line_items[1].quantity"`, …
513
+ - **Singular** — `"billing_address.street"`, `"billing_address.city"`, …
514
+
515
+ ### Controller integration
516
+
517
+ Strong params for HTML forms use the `_attributes` suffix:
518
+
519
+ ```ruby
520
+ def order_params
521
+ params.require(:order).permit(
522
+ :customer_name,
523
+ line_items_attributes: [:id, :name, :quantity, :_destroy],
524
+ billing_address_attributes: [:street, :city, :postcode]
525
+ )
526
+ end
527
+ ```
528
+
529
+ For API endpoints that send params without the suffix, use plain nested keys instead:
530
+
531
+ ```ruby
532
+ def order_params
533
+ params.require(:order).permit(
534
+ :customer_name,
535
+ line_items: [:name, :quantity],
536
+ billing_address: [:street, :city, :postcode]
537
+ )
538
+ end
539
+ ```
540
+
541
+ ### Initialising the form with pre-built nested records
542
+
543
+ When rendering a new-record form, build the nested records on the resource before passing it to the form so that `fields_for` / `simple_fields_for` has objects to iterate over:
544
+
545
+ ```ruby
546
+ def new
547
+ order = Order.new
548
+ order.line_items.build # collection — build at least one blank item
549
+ order.build_billing_address # singular
550
+
551
+ @form = CreateOrderForm.new(order, current_user)
552
+ end
553
+ ```
554
+
555
+ ```erb
556
+ <%= form_with model: @form, url: orders_path do |f| %>
557
+ <%= f.text_field :customer_name %>
558
+
559
+ <%= f.fields_for :line_items do |lf| %>
560
+ <%= lf.text_field :name %>
561
+ <%= lf.number_field :quantity %>
562
+ <% end %>
563
+
564
+ <%= f.fields_for :billing_address do |af| %>
565
+ <%= af.text_field :street %>
566
+ <%= af.text_field :city %>
567
+ <%= af.text_field :postcode %>
568
+ <% end %>
569
+
570
+ <%= f.submit %>
571
+ <% end %>
572
+ ```
573
+
574
+ ### SimpleForm support
575
+
576
+ Nested form objects returned by `nested_attributes_for` / `nested_attribute_for` are compatible with [simple_form](https://github.com/heartcombo/simple_form). The anonymous class created by an inline block is given a synthetic `model_name` so that SimpleForm can infer input names and error wrappers correctly.
577
+
578
+ Use `simple_fields_for` in place of `fields_for` and `f.input` in place of the individual field helpers:
579
+
580
+ ```erb
581
+ <%= simple_form_for @form, url: orders_path do |f| %>
582
+ <%= f.input :customer_name %>
583
+
584
+ <%= f.simple_fields_for :line_items do |lf| %>
585
+ <%= lf.input :name %>
586
+ <%= lf.input :quantity %>
587
+ <% end %>
588
+
589
+ <%= f.simple_fields_for :billing_address do |af| %>
590
+ <%= af.input :street %>
591
+ <%= af.input :city %>
592
+ <%= af.input :postcode %>
593
+ <% end %>
594
+
595
+ <%= f.submit %>
596
+ <% end %>
597
+ ```
598
+
599
+ Validation errors on nested fields are promoted to the parent form with structured keys (e.g. `"line_items[0].name"`), which SimpleForm resolves back to the correct field and renders inline error messages automatically.
600
+
601
+ ## Collections
602
+
603
+ Use `collection_for` to declare collection-backed select fields on your form, with optional inclusion validation.
604
+
605
+ ```ruby
606
+ class CreatePostForm < WellFormed::ResourceForm
607
+ resource_alias :post
608
+
609
+ attribute :user_id, :integer
610
+
611
+ collection_for :user_id, validate: true do
612
+ User.order(:name)
613
+ end
614
+ end
615
+ ```
616
+
617
+ `collection_for` generates a `collection_for_<name>` instance method that returns an ActiveRecord relation. The block is evaluated in the context of the form instance, so `user`, `resource`, and any other instance methods are available — useful for scoping:
618
+
619
+ ```ruby
620
+ collection_for :user_id do
621
+ User.where(organisation: resource.organisation)
622
+ end
623
+ ```
624
+
625
+ ### Validation
626
+
627
+ Pass `validate: true` to automatically validate that the submitted value exists within the collection, matched by `:id`:
628
+
629
+ ```ruby
630
+ collection_for :user_id, validate: true do
631
+ User.order(:name)
632
+ end
633
+ ```
634
+
635
+ Validation uses `collection.where(id: value).pluck(:id)` — a single scoped query rather than loading all records into memory. Blank values are always accepted (`allow_blank: true`); pair with a `presence` validator when the field is required.
636
+
637
+ To match on a field other than `:id`, pass the field name:
638
+
639
+ ```ruby
640
+ collection_for :status, validate: :code do
641
+ Status.active
642
+ end
643
+ ```
644
+
645
+ ### Code-to-value resolution (`resolves_to:`)
646
+
647
+ Use `resolves_to:` when the form accepts one representation of a value (e.g. a code string or an integer id) but the resource stores a different one. After validation, the attribute is automatically replaced with the resolved value via a `validate` callback.
648
+
649
+ `resolves_to:` requires `validate:` to be a Symbol — the field used to look up the record. If the lookup fails, an `:inclusion` error is added and no transformation is applied.
650
+
651
+ #### Code → id
652
+
653
+ The form accepts a code string; the resource stores the integer id:
654
+
655
+ ```ruby
656
+ class CreatePostForm < WellFormed::ResourceForm
657
+ resource_alias :post
658
+
659
+ attribute :user_id # untyped — accepts a code string initially
660
+
661
+ collection_for :user_id, validate: :code, resolves_to: :id do
662
+ User.all
663
+ end
664
+ end
665
+
666
+ # Submitting "ALICE" resolves to user.id and saves that integer
667
+ form = CreatePostForm.new(Post.new, current_user, { user_id: "ALICE" })
668
+ form.save # post.user_id == 42
669
+ ```
670
+
671
+ `resolves_to: true` is shorthand for `resolves_to: :id`.
672
+
673
+ #### Id → code
674
+
675
+ The form accepts an integer id; the resource stores the code string:
676
+
677
+ ```ruby
678
+ class CreatePostForm < WellFormed::ResourceForm
679
+ resource_alias :post
680
+
681
+ attribute :user_code # untyped — accepts an id initially
682
+
683
+ collection_for :user_code, validate: :id, resolves_to: :code do
684
+ User.all
685
+ end
686
+ end
687
+
688
+ # Submitting 42 resolves to user.code and saves that string
689
+ form = CreatePostForm.new(Post.new, current_user, { user_code: 42 })
690
+ form.save # post.user_code == "ALICE"
691
+ ```
692
+
693
+ #### Edit forms
694
+
695
+ For both directions, `resource_defaults` is automatically overridden to reverse-populate the attribute with the *input* representation when loading an existing record — so the form pre-fills with the value the user expects to see and edit:
696
+
697
+ ```ruby
698
+ # Code → id form: post.user_id is 42, form pre-fills user_id with "ALICE"
699
+ form = CreatePostForm.new(existing_post, current_user)
700
+ form.user_id # => "ALICE"
701
+
702
+ # Id → code form: post.user_code is "ALICE", form pre-fills user_code with 42
703
+ form = CreatePostForm.new(existing_post, current_user)
704
+ form.user_code # => 42
705
+ ```
706
+
707
+ ### View integration
708
+
709
+ Use `collection_for_<name>` directly in the view to build the select:
710
+
711
+ ```erb
712
+ <%= f.collection_select :user_id, @form.collection_for_user_id, :id, :name, { include_blank: true } %>
713
+ ```
714
+
715
+ ### SimpleForm integration
716
+
717
+ With SimpleForm, pass the collection explicitly via the `collection:` option:
718
+
719
+ ```erb
720
+ <%= f.input :user_id, collection: @form.collection_for_user_id %>
721
+ ```
722
+
723
+ Alternatively, define a custom SimpleForm input that auto-discovers `collection_for_<name>` on the form object, so the view needs no explicit collection reference:
724
+
725
+ ```ruby
726
+ # app/inputs/collection_for_input.rb
727
+ class CollectionForInput < SimpleForm::Inputs::CollectionSelectInput
728
+ def collection
729
+ if object.respond_to?(:"collection_for_#{attribute_name}")
730
+ @collection ||= object.public_send(:"collection_for_#{attribute_name}")
731
+ else
732
+ super
733
+ end
734
+ end
735
+ end
736
+ ```
737
+
738
+ Then declare the field with `as: :collection_for`:
739
+
740
+ ```erb
741
+ <%= f.input :user_id, as: :collection_for %>
742
+ ```
743
+
744
+ ## PORO support
745
+
746
+ When the resource is a plain Ruby object that does not respond to `save` — a value object, a service-layer struct, an API client payload — inherit from `WellFormed::Struct` instead. It has the same interface as `WellFormed::ResourceForm` but replaces the default `save` behaviour with a `perform` method you define yourself.
747
+
748
+ Form attributes are still auto-assigned to the resource (via individual setters), and `before_save`/`after_save` callbacks still work. The AR-specific helpers `after_save_commit` and `save_within_transaction` are not available.
749
+
750
+ ```ruby
751
+ class CreateInvoiceForm < WellFormed::Struct
752
+ resource_alias :invoice
753
+
754
+ attribute :recipient_email, :string
755
+ attribute :amount_cents, :integer
756
+
757
+ validates :recipient_email, presence: true
758
+ validates :amount_cents, numericality: { greater_than: 0 }
759
+
760
+ after_validation :normalise_email
761
+ after_save :log_issuance
762
+
763
+ private
764
+
765
+ def perform
766
+ InvoiceService.issue(invoice)
767
+ end
768
+
769
+ def normalise_email
770
+ self.recipient_email = recipient_email&.strip&.downcase
771
+ end
772
+
773
+ def log_issuance
774
+ Rails.logger.info("Invoice issued to #{recipient_email}")
775
+ end
776
+ end
777
+ ```
778
+
779
+ `submit` works the same way — it runs validations, assigns attributes to the resource, calls `perform`, and returns the resource on success or `false` on failure.
780
+
781
+ If there's no meaningful resource to wrap at all, use `WellFormed::ActionForm` instead.
782
+
783
+ ## Translations
784
+
785
+ WellFormed adds a generic base error when a save or perform fails with no errors already present. The default messages can be overridden in your application's locale files under the form's `resource_alias` key:
786
+
787
+ ```yaml
788
+ en:
789
+ activemodel:
790
+ errors:
791
+ models:
792
+ article: # matches resource_alias :article
793
+ could_not_be_saved: "could not be saved"
794
+ publish_article: # matches resource_alias :publish_article
795
+ could_not_be_performed: "could not be performed"
796
+ ```
797
+
798
+ To override globally for all forms, use the shared messages scope:
799
+
800
+ ```yaml
801
+ en:
802
+ activemodel:
803
+ errors:
804
+ messages:
805
+ could_not_be_saved: "could not be saved"
806
+ could_not_be_performed: "could not be performed"
807
+ ```
808
+
809
+ ## Simple variants
810
+
811
+ Each base class has a **Simple** counterpart that drops the `user` argument from the constructor — useful in contexts where there is no current user, such as background jobs, microservices, or data-import pipelines.
812
+
813
+ | With user | Without user | Constructor |
814
+ |-----------|--------------|-------------|
815
+ | `WellFormed::ResourceForm` | `WellFormed::SimpleResource` | `(resource, params = {})` |
816
+ | `WellFormed::ActionForm` | `WellFormed::SimpleAction` | `(resource, params = {})` |
817
+ | `WellFormed::Struct` | `WellFormed::SimpleStruct` | `(resource, params = {})` |
818
+
819
+ Everything else — attributes, validations, callbacks, collections, nested attributes — works identically. Calling `form.user` on a Simple class raises `NoMethodError`.
820
+
821
+ ```ruby
822
+ class SyncProductForm < WellFormed::SimpleResource
823
+ resource_alias :product
824
+
825
+ attribute :name, :string
826
+ attribute :price, :decimal
827
+
828
+ validates :name, presence: true
829
+ validates :price, numericality: { greater_than: 0 }
830
+ end
831
+
832
+ form = SyncProductForm.new(Product.new, { name: "Widget", price: 9.99 })
833
+ form.save # => true / false
834
+ ```
835
+
836
+ ### `WellFormed::WithUser`
837
+
838
+ If you need to add user support to a custom class that already inherits from one of the Simple variants, prepend `WellFormed::WithUser`. It overrides the constructor to accept `(resource, user, params = {})` and exposes `form.user`:
839
+
840
+ ```ruby
841
+ class AuditedSyncForm < WellFormed::SimpleResource
842
+ prepend WellFormed::WithUser
843
+
844
+ before_save { AuditLog.record(user, resource) }
845
+ end
846
+
847
+ form = AuditedSyncForm.new(record, current_user, params)
848
+ form.user # => current_user
849
+ ```
850
+
851
+ `ResourceForm`, `ActionForm`, and `Struct` are themselves implemented this way — they inherit from their Simple counterpart and prepend `WithUser`.
852
+
853
+ ## Development
854
+
855
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
856
+
857
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
858
+
859
+ ## Contributing
860
+
861
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/resource_form. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/resource_form/blob/main/CODE_OF_CONDUCT.md).
862
+
863
+ ## License
864
+
865
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
866
+
867
+ ## Code of Conduct
868
+
869
+ Everyone interacting in the ResourceForm project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/resource_form/blob/main/CODE_OF_CONDUCT.md).