Module: Permittable
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/permittable.rb,
lib/permittable/railtie.rb,
lib/permittable/version.rb,
lib/permittable/column_guard.rb,
lib/permittable/error_envelope.rb,
lib/permittable/filter_parameter_registry.rb
Overview
Declarative, typed params contracts — what strong parameters would be if it
also knew types, bounds, defaults, and why a request was bad. Strong
parameters (and Rails 8's params.expect) only answer "which keys may
pass"; a Permittable contract additionally casts each field, validates it,
applies defaults, and turns every failure into a machine-readable 422 — and,
because the contract is class-level data rather than code inside the action,
it is introspectable (permittable_contracts) and can be checked against a
model's schema at class-load time.
class UsersController < ApplicationController
include Permittable
permit_params :create, :update, root: :user, model: User do
required :name, :string, length: 1..80, normalize: :squish
required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
optional :age, :integer, in: 18..120
optional :ssn, :string, sensitive: true
optional :plan, :string, in: %w[free pro], default: "free"
array :tag_names, of: :string, length: 0..10, virtual: true
optional :address do
required :city, :string
optional :zip, :string, format: /\A\d{5}\z/
end
end
def create
user = User.create!(permitted_params) # cast, validated, defaulted
end
end
THE LAST MATCHING RULE WINS: contracts are configuration, so a base controller's catch-all (a rule declared with no actions) is overridden by a later action-specific declaration in a subclass. Rules accumulate via reassignment, never mutation, so subclasses inherit copy-on-write.
Schema-drift guard — the reason model: exists. Every non-virtual scalar
field is checked against the model's columns when the macro runs, i.e. at
controller class load. Production eager-loads controllers, so a column
dropped by a migration fails the deploy, not the request; the error carries
a copy-paste migration hint. Fields not backed by a column
(password_confirmation, terms flags) opt out with virtual: true; nested
and array fields are implicitly virtual. When the schema is unreachable
(db:create, assets:precompile) the check skips. In CI, one
Rails.application.eager_load! spec exercises every contract in the app.
Validation is LAZY: it runs on the first permitted_params call, so an
action that never reads params never pays. enforce: true installs the
check as a before_action instead (reject before the action body runs).
Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
and silently corrupting untrusted input is exactly what a contract must not
do. A value the type cannot faithfully represent is a violation, not a
guess. nil and "" are both treated as ABSENT (the query-param convention):
absent optional fields are OMITTED from the result (so partial updates never
nil-out columns), absent required fields violate, and default: fills
absence. Clearing a column to NULL is therefore outside a contract's
vocabulary — do that explicitly.
Failures raise Permittable::InvalidParameters, rescued (on a real
controller) into the shared ErrorEnvelope shape with details: entries of
{ param: "user.address.zip", code: "format" }; a missing root: key
renders 400, field violations 422. Every violation also instruments
"invalid_parameters.permittable" so failures can be dashboarded.
sensitive: true registers the field name with
Permittable.filter_parameter_registry (swappable — a host gem can point it
at its own registry), consulted at filter time by the proc
Permittable::Railtie appends to config.filter_parameters.
OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
Two layers, both operating on the validated COPY (the request's params is
never touched):
* `transform:` (scalar and array fields) — a callable applied AFTER cast
and validation to reshape that field's output, e.g.
`transform: ->(v) { v.split(",") }` turns a validated delimited String
into an Array. Runs only on request-supplied values: absent fields stay
absent and `default:` values are authored in final shape.
* `finalize do |p| ... end` (once per contract) — runs after every field
validated cleanly, receives the result hash, and must return the
(possibly restructured) Hash: combine parallel fields, build value
objects, drop scaffolding keys. It executes on a bare runner — NOT the
controller — so contracts stay pure data + pure functions; the only
extra vocabulary is `violate!(param, code)`, which records one violation
and halts the block immediately (the whole contract then fails as a
normal 422), making finalize double as the cross-field validation seam
("ends_at after starts_at").
Naming note: some legacy stacks (InheritedResources) define their own
permitted_params; don't include both on one controller.
Defined Under Namespace
Modules: Coercion, ColumnGuard, ErrorEnvelope Classes: ContractBuilder, FilterParameterRegistry, FinalizeRunner, InvalidParameters, Railtie
Constant Summary collapse
- LABEL =
"Permittable".freeze
- SCALAR_TYPES =
%i[string integer float decimal boolean date datetime].freeze
- UNKNOWN_MODES =
%i[ignore log error].freeze
- ROUTING_KEYS =
Rails merges routing bookkeeping into params; a top-level (root: false) unknown-keys check must not flag them.
%w[controller action format].freeze
- NORMALIZERS =
{ squish: ->(v) { v.squish }, strip: ->(v) { v.strip }, downcase: ->(v) { v.downcase }, upcase: ->(v) { v.upcase }, email: ->(v) { v.strip.downcase } }.freeze
- VERSION =
"0.1.2".freeze
Class Attribute Summary collapse
-
.filter_parameter_registry ⇒ Object
Duck-typed sink for
sensitive:field names (#add / #include? / #to_proc / #reset!).
Instance Method Summary collapse
-
#enforce_params_contract ⇒ Object
before_action entry point (public so hosts can
skip_before_action :enforce_params_contract). -
#permitted_params(action = nil) ⇒ Object
The contract's output: a HashWithIndifferentAccess of cast, validated, defaulted values for the given action (default: the current action).
-
#render_invalid_parameters(error) ⇒ Object
rescue_from target — renders through the shared envelope (the host's render_error when present, the identical inline shape otherwise).
Class Attribute Details
.filter_parameter_registry ⇒ Object
Duck-typed sink for sensitive: field names (#add / #include? /
#to_proc / #reset!). Swappable so a host gem can pool registrations into
its own registry (concerns_on_rails does exactly this).
131 132 133 134 135 |
# File 'lib/permittable.rb', line 131 def filter_parameter_registry @filter_parameter_registry || @registry_mutex.synchronize do @filter_parameter_registry ||= FilterParameterRegistry.new end end |
Instance Method Details
#enforce_params_contract ⇒ Object
before_action entry point (public so hosts can skip_before_action :enforce_params_contract). Only rules that opted in with
enforce: true validate here.
625 626 627 628 629 630 631 632 |
# File 'lib/permittable.rb', line 625 def enforce_params_contract action = permittable_action_name return nil unless action rule = self.class.permit_rule_for(action) permitted_params(action) if rule && rule[:enforce] nil end |
#permitted_params(action = nil) ⇒ Object
The contract's output: a HashWithIndifferentAccess of cast, validated, defaulted values for the given action (default: the current action). Absent optional fields are omitted. Raises InvalidParameters on violation; raises ArgumentError when no contract covers the action (that is a programmer error, not a client error). Memoized per action.
609 610 611 612 613 614 615 616 617 618 619 620 |
# File 'lib/permittable.rb', line 609 def permitted_params(action = nil) action = (action || permittable_action_name).to_s raise ArgumentError, "#{LABEL}: no action given and action_name is not set" if action.empty? @permittable_validated ||= {} return @permittable_validated[action] if @permittable_validated.key?(action) rule = self.class.permit_rule_for(action) raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule @permittable_validated[action] = validate_params_contract!(rule) end |
#render_invalid_parameters(error) ⇒ Object
rescue_from target — renders through the shared envelope (the host's render_error when present, the identical inline shape otherwise).
636 637 638 639 640 641 |
# File 'lib/permittable.rb', line 636 def render_invalid_parameters(error) ErrorEnvelope.render( self, message: error., status: error.status, code: "invalid_parameters", details: error.details ) end |