Module: Permittable

Extended by:
ActiveSupport::Concern
Defined in:
lib/permittable.rb,
lib/permittable/rspec.rb,
lib/permittable/railtie.rb,
lib/permittable/version.rb,
lib/permittable/contract.rb,
lib/permittable/open_api.rb,
lib/permittable/generator.rb,
lib/permittable/json_schema.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).

MONITOR MODE — the rollout switch. mode: :monitor on a rule (or Permittable.mode = :monitor app-wide; a rule's own mode: wins) runs the full pipeline but REPORTS violations instead of rejecting: the same "invalid_parameters.permittable" event fires (payload mode: :monitor), the logger warns, and permitted_params returns the raw params passed through untouched — no casts, no defaults, no transforms — so behaviour is identical to the pre-contract app (a missing root: passes an empty hash; a rootless contract drops only the router's bookkeeping keys). Monitor rules validate eagerly in the before_action regardless of enforce:, because telemetry must not depend on the action calling permitted_params — legacy actions still reading params directly are exactly the ones being monitored — and monitoring can never halt the request. permittable_violations reads the recorded details ([] when the request was clean).

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.

Violation MESSAGES stay machine-first (the code is the contract), but a field can attach human-readable copy with message: — one String for every code (message: "must be a valid email") or a Hash per code (message: { missing: "is required", format: "must be a valid email" }). A resolved message rides into the detail entry as message: and replaces the "(code)" rendering in the exception's summary line; codes without a message keep the bare shape, so nothing changes for contracts that don't opt in. violate! in finalize accepts the same via message:.

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, Generator, JsonSchema, Matchers, OpenAPI Classes: Contract, 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
MODES =
%i[enforce monitor].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.5.1".freeze

Class Attribute Summary collapse

Class Method 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).



157
158
159
160
161
# File 'lib/permittable.rb', line 157

def filter_parameter_registry
  @filter_parameter_registry || @registry_mutex.synchronize do
    @filter_parameter_registry ||= FilterParameterRegistry.new
  end
end

Class Method Details

.default_message_for(code) ⇒ Object

App-wide fallback copy for a violation code, looked up through I18n under permittable.errors. ("missing", "inclusion", or any Symbol a validate: returned). Consulted only when the field declares no matching message: of its own, and only when the host app has I18n — without translations (or without I18n) details keep the bare { param:, code: } shape, so nothing changes for apps that don't opt in. Only a String translation counts; anything else (a nested Hash, a missing-translation object) is ignored rather than leaked to clients.



193
194
195
196
197
198
# File 'lib/permittable.rb', line 193

def default_message_for(code)
  return nil unless defined?(::I18n) && ::I18n.respond_to?(:t)

  message = ::I18n.t("permittable.errors.#{code}", default: nil)
  message.is_a?(String) ? message : nil
end

.modeObject

App-wide default for rules that don't declare their own mode:. :enforce (the default) rejects violating requests; :monitor reports them — same instrumentation event with payload mode: :monitor, plus a logger.warn — and lets the request proceed with the raw params passed through. This is the rollout switch for brownfield adoption: set it from an initializer (Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym) and flip controllers to their final mode one at a time, since a rule's own mode: always wins over this default.



174
175
176
# File 'lib/permittable.rb', line 174

def mode
  @mode || :enforce
end

.mode=(value) ⇒ Object

Raises:

  • (ArgumentError)


178
179
180
181
182
183
# File 'lib/permittable.rb', line 178

def mode=(value)
  value = value.to_sym
  raise ArgumentError, "#{LABEL}: mode must be one of #{MODES.join(', ')}" unless MODES.include?(value)

  @mode = value
end

Instance Method Details

#enforce_params_contractObject

before_action entry point (public so hosts can skip_before_action :enforce_params_contract). Two kinds of rule validate here: those that opted in with enforce: true, and monitor-mode rules — monitoring must not depend on the action calling permitted_params (legacy actions still reading params directly are exactly the ones being monitored), and it can never halt the request because monitor mode never raises.



742
743
744
745
746
747
748
749
# File 'lib/permittable.rb', line 742

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] || permittable_mode(rule) == :monitor)
  nil
end

#permittable_violations(action = nil) ⇒ Object

The violation details recorded by validating action (default: the current action) — [] when the request satisfied the contract. Triggers the same memoized validation as permitted_params, so under monitor mode this is the request-level observable ("what would have been rejected?"); under enforce mode it swallows the raise and hands back the details, which makes "would this request fail?" a one-liner in tests.



758
759
760
761
762
763
764
765
766
767
768
769
# File 'lib/permittable.rb', line 758

def permittable_violations(action = nil)
  action = (action || permittable_action_name).to_s
  @permittable_violations ||= {}
  unless @permittable_violations.key?(action)
    begin
      permitted_params(action)
    rescue InvalidParameters
      # validation recorded the details before raising
    end
  end
  @permittable_violations.fetch(action)
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)


723
724
725
726
727
728
729
730
731
732
733
734
# File 'lib/permittable.rb', line 723

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, action)
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).



773
774
775
776
777
778
# File 'lib/permittable.rb', line 773

def render_invalid_parameters(error)
  ErrorEnvelope.render(
    self, message: error.message, status: error.status,
          code: "invalid_parameters", details: error.details
  )
end