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.1".freeze

Class Attribute Summary collapse

Instance Method Summary collapse

Class Attribute Details

.filter_parameter_registryObject

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_contractObject

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.

Raises:

  • (ArgumentError)


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.message, status: error.status,
          code: "invalid_parameters", details: error.details
  )
end