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
-
.filter_parameter_registry ⇒ Object
Duck-typed sink for
sensitive:field names (#add / #include? / #to_proc / #reset!).
Class Method Summary collapse
-
.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). -
.mode ⇒ Object
App-wide default for rules that don't declare their own mode:.
- .mode=(value) ⇒ Object
Instance Method Summary collapse
-
#enforce_params_contract ⇒ Object
before_action entry point (public so hosts can
skip_before_action :enforce_params_contract). -
#permittable_violations(action = nil) ⇒ Object
The violation details recorded by validating
action(default: the current action) — [] when the request satisfied the 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).
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 (code) return nil unless defined?(::I18n) && ::I18n.respond_to?(:t) = ::I18n.t("permittable.errors.#{code}", default: nil) .is_a?(String) ? : nil end |
.mode ⇒ Object
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 |
Instance Method Details
#enforce_params_contract ⇒ Object
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.
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., status: error.status, code: "invalid_parameters", details: error.details ) end |