Skip to content

Add reusable field groups: Permittable.fields and use - #16

Open
VSN2015 wants to merge 1 commit into
masterfrom
feature/field-groups
Open

Add reusable field groups: Permittable.fields and use#16
VSN2015 wants to merge 1 commit into
masterfrom
feature/field-groups

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The duplication

A growing API produces two kinds the DSL had no answer for:

  1. The address block three controllers want.
  2. The update contract that is the create contract with nothing mandatory.

Both were copy-paste — and copy-paste in a contract is precisely how validation and documentation drift apart. It's also the one thing docs/comparison.md concedes to dry-validation ("reusable schema fragments shared across services").

The fix

AddressFields = Permittable.fields do
  required :city, :string, length: 1..80
  optional :zip,  :string, format: /\A\d{5}\z/
end

UserFields = Permittable.fields do
  required :name,  :string
  required :email, :string, format: URI::MailTo::EMAIL_REGEXP
  optional :plan,  :string, in: %w[free pro], default: "free"
  optional :address do
    use AddressFields          # groups compose
  end
end

class UsersController < ApplicationController
  include Permittable

  permit_params :create, root: :user, model: User do
    use UserFields
  end

  # PATCH: the same fields, nothing mandatory.
  permit_params :update, root: :user, model: User do
    use UserFields, optional: true
  end
end

A FieldGroup is a frozen, reusable field list — the same data a contract's fields already are. use splices it in at the point of use, in the group's own order, exactly as if those fields had been typed there, so everything downstream is unchanged by construction: request-time behaviour, the drift guard, sensitive: registration, the exported schema. It works at the top level of a contract, inside a nested or array block, and inside another group.

Option Meaning
optional: true Relax every spliced field. Top level only — if a client sends an address at all, the address's own required sub-fields still hold, which is what a PATCH actually means. Types, bounds and default: untouched
only: / except: Select a subset, in the group's own order. Mutually exclusive

Mistakes fail at class load, not silently

  • Because a group is built by the same builder a contract is, its declarations are validated when the group is defined — a typo fails once, at the group, instead of at every contract using it.
  • only:/except: naming a field the group doesn't declare raises, naming what the group does declare. A typo cannot quietly drop a field.
  • A field declared twice still raises, which is what makes overriding one field of a group deliberate rather than positional:
permit_params :create do
  use AddressFields, except: %i[city]
  required :city, :string, length: 1..5   # this contract's own stricter city
end

What a group deliberately is not

A contract. No root:, unknown:, model: or mode: — those describe the request being validated, not a set of fields — and finalize is rejected for the same reason.

But Permittable::Contract now answers #fields, so use SomeContract lets a webhook payload and a controller action share one definition instead of two that drift.

Verification

  • 216 examples, 0 failures (17 new in spec/field_group_spec.rb, written before the implementation)
  • RuboCop clean, 28 files
  • No changes to the exporters, matchers or generator were needed — a spliced field is an ordinary field, which is the point

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #16: Permittable.fields and the use macro provide a clean mechanism for sharing parameter schemas across endpoints while keeping declarations immutable and validated at definition time.

Comment thread lib/permittable.rb
# overriding one field of a group is deliberate: `use G, except: [:city]`
# and then declare `:city` yourself.
def use(group, only: nil, except: nil, optional: false)
fields = select_group_fields!(group_fields!(group), only: only, except: except)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relaxing only the top-level spliced fields via optional: true is an intuitive design choice: it makes use BaseFields, optional: true directly usable for PATCH endpoints without inadvertently making nested sub-fields optional when provided.

Comment thread lib/permittable.rb
end

def assert_group_names!(fields, names, label)
wanted = Array(names).map(&:to_sym)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validating that only: and except: names match declared fields at class load time prevents silent typos from inadvertently dropping contract fields.

A growing API produces two kinds of duplication the DSL had no answer
for: the address block three controllers want, and the update contract
that is the create contract with nothing mandatory. Both were
copy-paste, and copy-paste in a contract is how validation and
documentation drift apart.

Permittable.fields { ... } builds a FieldGroup — a frozen, reusable
field list, the same data a contract's fields already are — and the
builder's `use` verb splices one in at the point of use, in the group's
own order, exactly as if those fields had been typed there. Everything
downstream is therefore unchanged by construction: request-time
behaviour, the drift guard, sensitive: registration, the exported
schema. It works at the top level of a contract, inside a nested or
array block, and inside another group, so groups compose.

`use G, optional: true` relaxes every spliced field, which is the
create-to-update answer: types, bounds and default: intact, nothing
mandatory. It relaxes the top level only — if a client sends an address
at all, the address's own required sub-fields still hold, which is what
a PATCH actually means.

Because a group is built by the same builder a contract is, its
declarations are validated when the GROUP is defined: a typo fails
once, at the group, instead of at every contract using it. Two more
mistakes fail at class load rather than silently — only:/except:
naming a field the group doesn't declare (so a typo cannot quietly drop
a field), and a field declared twice, which is what makes overriding
one field of a group deliberate rather than positional.

A group is deliberately not a contract: no root:, unknown:, model: or
mode:, and finalize is rejected, because those describe the request
being validated rather than a set of fields. Permittable::Contract now
answers #fields, though, so `use SomeContract` lets a webhook payload
and a controller action share one definition instead of two that
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the feature/field-groups branch from 6480a65 to 42a1aca Compare September 11, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant