ActionParamsContract
Declarative Rails controller parameter validation, powered by dry-validation. You can define a schema next to your actions. The gem installs an around_action, changes params to the declared types, and raises a structured error or exposes the failures for you to handle.
Requirements
Ruby ≥ 3.1, Rails ≥ 7.0.
Installation
gem "action_params_contract"
Then run bundle install.
Usage
Declare the schema in the controller body. If successful, params is replaced with the typed or changed hash. If it fails, ActionParamsContract::InvalidParamsError is raised before the action runs.
class ArticlesController < ApplicationController
ActionParamsContract.validate! do
params do
root :article
optional(:page).filled(:integer).default(1)
on_create do
required(:article).hash do
required(:title).filled(:string)
required(:body).filled(:string)
end
end
on_update { required(:id).filled(:integer) }
end
end
def create
Article.create!(ActionParamsContract.filtered_params)
end
end
Rescue the error in your base controller for a consistent response:
rescue_from ActionParamsContract::InvalidParamsError do |exception|
render json: { errors: exception.errors }, status: :bad_request
end
Strict vs. Soft
validate!raisesInvalidParamsErroron failure, so the action never runs.validaterecords errors in the controller and lets the action run. You can inspect them usingActionParamsContract.params_errors.
ActionParamsContract.validate do
params { required(:title).filled(:string) }
end
def create
return render(json: { errors: ActionParamsContract.params_errors }, status: :unprocessable_entity) if ActionParamsContract.params_errors.present?
Article.create!(ActionParamsContract.filtered_params)
end
A controller can register only one, not both. A second call or mixing validate and validate! raises DuplicateRegistrationError at load time.
Action-Conditional DSL
Scope rules to specific actions:
on_create { ... },on_update { ... },on_index { ... },on_destroy { ... }on_actions(:create, :update) { ... }for multiple actionscurrent_action?(:create)as a predicate form
These also work inside rule blocks, allowing cross-field rules to branch per action.
filtered_params and root
ActionParamsContract.filtered_params returns the validated params with Rails internals (such as format, controller, action, and locale) removed. If your schema declares root :key, the sub-hash under that key is returned unwrapped, ready to use for a model:
# Request body: { article: { title: "Hi", body: "..." } }
ActionParamsContract.filtered_params # => { "title" => "Hi", "body" => "..." }
The plain params also works inside the action, holding the same typed or changed hash on success.
It does not negate or override the strong parameters; it can live alongside them. params is still a ActionController::Parameters instance, meaning you can call params.require(:article).permit(:title, :body) as usual if you choose. However, the difference now is that with your schema defined, you do not typically need this. Your values will be returned in a regular Hash by way of filtered_params, having been type-checked and type-coerced according to the defined schema, with any undeclared keys removed automatically.
Configuration
Override the list of Rails-internal keys you want to keep:
# config/initializers/action_params_contract.rb
ActionParamsContract.configure do |config|
config.whitelisted_params = %i[format controller action locale tenant_id]
end
Error Tracking
InvalidParamsError uses a stable message ("Params failed validation"), grouping occurrences into a single issue in tools like Sentry instead of breaking them down per input. Attach #errors as structured context in your rescue handler:
rescue_from ActionParamsContract::InvalidParamsError do |exception|
Sentry.with_scope do |scope|
scope.set_context("params_validation", { errors: exception.errors })
scope.(controller: controller_path, action: action_name)
Sentry.capture_exception(exception)
end
render json: { errors: exception.errors }, status: :bad_request
end
Equivalents include: Bugsnag.notify(exception) { |r| r.add_metadata(:params_validation, exception.errors) }, Honeybadger.notify(exception, context: { errors: exception.errors }), and Rollbar.error(exception, errors: exception.errors). Keep exception.errors out of the message itself, as it can disrupt grouping.
What This Gem Is Not
Validation semantics, types, predicates, rule blocks, and error formatting are all part of dry-validation. Refer to its documentation for the complete reference. This gem provides Rails integration, action conditionals, root unwrapping, and the default macro on top.
License
MIT.