ActionFigure provides a two-layer validation pipeline powered by dry-validation. The pipeline runs before your #call method, so if validation fails, your operation logic is never executed. The caller receives an UnprocessableContent result containing structured errors.
The two layers are:
params_schema-- structural validation and type coercion (powered by dry-schema)rules-- validation rules that run only after the schema passes
If no params_schema is defined, params: passes through to your #call method as-is — no validation, no coercion, no stripping of extra keys. This lets you rely on upstream validation (e.g., Rack middleware like committee) while still using ActionFigure for orchestration and response formatting.
params_schema accepts a block written in the dry-schema DSL. It defines the shape of your input: which keys are allowed, which are required, and what types they must be.
class Users::CreateAction
include ActionFigure[:jsend]
params_schema do
required(:email).filled(:string)
required(:name).filled(:string)
optional(:age).filled(:integer)
optional(:newsletter).filled(:bool)
end
def call(params:, **)
user = User.create(params)
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
resource = UserBlueprint.render_as_hash(user)
Ok(resource:)
end
endrequired(:field)-- the key must be present in the input. If missing, validation fails before rules ever run.optional(:field)-- the key may be omitted. If present, it still must satisfy the declared type and predicates.
Because ActionFigure uses a params schema (not a plain schema), dry-schema automatically coerces string values into their declared types. This is essential for web requests where everything arrives as a string.
params_schema do
required(:quantity).filled(:integer)
required(:price).filled(:float)
required(:active).filled(:bool)
end
# Input: { quantity: "25", price: "9.99", active: "true" }
# Output: { quantity: 25, price: 9.99, active: true }Common coercible types: :string, :integer, :float, :decimal, :bool, :date, :time, :date_time.
rules run after the schema passes, giving you access to fully validated and coerced values. Use rules for constraints that span multiple fields or require context that the schema DSL cannot express (e.g., database lookups). ActionFigure includes several cross-parameter helpers (documented below) to simplify common multi-field rules.
class Users::CreateAction
include ActionFigure[:jsend]
params_schema do
required(:email).filled(:string)
required(:name).filled(:string)
end
rules do
rule(:email) do
if values[:email] && User.exists?(email: values[:email])
key.failure("is already taken")
end
end
end
def call(params:, **)
user = User.create(params)
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
Ok(resource: user)
end
end-
rulesmust be declared afterparams_schema. Declaring it without a schema raises:ArgumentError: rules requires params_schema to be defined -
Inside a rule block, access validated values with
values[:field]. -
Add an error to a specific field with
key(:field).failure("message"), orkey.failure("message")when the rule is scoped to a single field viarule(:field). -
Multiple rules can target the same field. All rules run even if earlier ones fail -- errors accumulate.
ActionFigure ships four helpers for common multi-field constraints. They are available inside rules blocks and save you from writing the same boilerplate patterns repeatedly.
All helpers share a consistent definition of "present": a field is considered present when its key exists in the validated values and its value is not nil. Notably, false counts as present -- only nil (or a missing key) counts as absent.
At most one of the listed fields may be present. If multiple are present, each present field receives the error message.
class Orders::SearchAction
include ActionFigure[:jsend]
params_schema do
optional(:order_id).filled(:string)
optional(:tracking_number).filled(:string)
optional(:customer_email).filled(:string)
end
rules do
exclusive_rule(:order_id, :tracking_number, :customer_email,
"provide only one search criterion")
end
def call(params:, **)
# exactly zero or one search key is guaranteed here
end
endGiven { order_id: "123", tracking_number: "TRK-456" }, both order_id and tracking_number receive the error. Passing zero fields is fine -- use any_rule if you need at least one.
At least one of the listed fields must be present. If none are present, every listed field receives the error message.
class Orders::SearchAction
include ActionFigure[:jsend]
params_schema do
optional(:order_id).filled(:string)
optional(:tracking_number).filled(:string)
optional(:customer_email).filled(:string)
end
rules do
any_rule(:order_id, :tracking_number, :customer_email,
"at least one search criterion is required")
end
def call(params:, **)
# guaranteed at least one search field is present
end
endGiven {}, all three fields receive the error. Given { order_id: "123", tracking_number: "TRK-456" }, validation passes -- multiple present fields are fine.
Exactly one of the listed fields must be present. If zero or more than one are present, every listed field receives the error message.
class Payments::CreateAction
include ActionFigure[:jsend]
params_schema do
required(:amount).filled(:integer)
optional(:credit_card_token).filled(:string)
optional(:bank_account_id).filled(:string)
optional(:wallet_id).filled(:string)
end
rules do
one_rule(:credit_card_token, :bank_account_id, :wallet_id,
"exactly one payment method is required")
end
def call(params:, **)
# exactly one payment method is guaranteed
end
endGiven { amount: 5000, credit_card_token: "tok_123", wallet_id: "wal_456" }, all three payment method fields receive the error. Given { amount: 5000 } with no payment method, all three also receive the error.
All listed fields must be present together, or all must be absent. A partial set causes every listed field to receive the error message.
class Users::CreateAction
include ActionFigure[:jsend]
params_schema do
required(:name).filled(:string)
optional(:street).filled(:string)
optional(:city).filled(:string)
optional(:zip).filled(:string)
end
rules do
all_rule(:street, :city, :zip,
"address fields must be provided together or not at all")
end
def call(params:, **)
# address is either complete or entirely absent
end
endGiven { name: "Jane", street: "123 Main St" }, all three address fields receive the error because only one of three is present. Given { name: "Jane" } (none present) or { name: "Jane", street: "123 Main St", city: "Portland", zip: "97201" } (all present), validation passes.
By default, dry-validation silently strips any keys that are not declared in the schema. Your #call method only ever sees the declared fields:
class Users::CreateAction
include ActionFigure[:jsend]
params_schema do
required(:email).filled(:string)
end
def call(params:, **)
params #=> { email: "jane@example.com" }
# :admin was silently removed
end
end
# Called with: { email: "jane@example.com", admin: true }If you prefer to reject unexpected parameters outright, enable the whiny_extra_params configuration option:
ActionFigure.configure do |config|
config.whiny_extra_params = true
endWith this enabled, passing undeclared parameters returns an UnprocessableContent result with a 422 status. The error shape is consistent with all other validation errors:
# Called with: { email: "jane@example.com", admin: true, role: "superuser" }
# Result errors:
{
admin: ["is not allowed"],
role: ["is not allowed"]
}Each extra key receives its own "is not allowed" error message. This check runs after schema validation succeeds, so you will see schema errors or extra-param errors, never both at the same time.
In Rails controllers, form data arrives as ActionController::Parameters rather than a plain Hash. ActionFigure handles this automatically: when it detects an object that responds to to_unsafe_h, it calls that method to convert it to a regular hash before validation.
This means you can pass params from a controller directly without calling permit, require, or to_h yourself -- the action's params_schema handles all of that:
class UsersController < ApplicationController
def create
render Users::CreateAction.call(
params:,
current_user: current_user
)
end
endPlain hashes work identically -- ActionFigure only calls to_unsafe_h when the method is available. This makes actions easy to test without constructing ActionController::Parameters objects:
result = Users::CreateAction.call(
params: { user: { email: "jane@example.com", name: "Jane" } }
)