V1.3: Parameters
Parameters are taken from the Rack env and passed as an argument to #call
.
They are similar to a Ruby Hash
, but they offer an expanded set of features.
Sources
Params can come from:
- Router variables (eg.
/books/:id
) - Query string (eg.
/books?title=Hanami
) - Request body (eg. a
POST
request to/books
)
Access
To access the value of a param, we can use the subscriber operator #[]
.
# apps/web/controllers/dashboard/index.rb
module Web
module Controllers
module Dashboard
class Index
include Web::Action
def call(params)
self.body = "Query string: #{ params[:q] }"
end
end
end
end
end
If we visit /dashboard?q=foo
, we should see Query string: foo
.
Symbol Access
Params and nested params can be referenced only via symbols.
params[:q]
params[:book][:title]
Now, what happens if the parameter :book
is missing from the request?
Because params[:book]
is nil
, we can’t access :title
.
In this case Ruby will raise a NoMethodError
.
We have a safe solution for our problem: #get
.
It accepts a list of symbols, where each symbol represents a level in our nested structure.
params.get(:book, :title) # => "Hanami"
params.get(:unknown, :nested, :param) # => nil instead of NoMethodError
Whitelisting
In order to show how whitelisting works, let’s create a new action:
bundle exec hanami generate action web signup#create
We want to provide self-registration for our users.
We build a HTML form which posts to an action that accepts the payload and stores it in the users
table.
That table has a boolean column admin
to indicate whether a person has administration permissions.
A malicious user can exploit this scenario by sending this extra parameter to our application, thereby making themselves an administrator.
We can easily fix this problem by filtering the allowed parameters that are permitted inside our application. Please always remember that params represent untrusted input.
We use .params
to map the structure of the (nested) parameters.
# apps/web/controllers/signup/create.rb
module Web::Controllers::Signup
class Create
include Web::Action
params do
required(:email).filled
required(:password).filled
required(:address).schema do
required(:country).filled
end
end
def call(params)
puts params[:email] # => "alice@example.org"
puts params[:password] # => "secret"
puts params[:address][:country] # => "Italy"
puts params[:admin] # => nil
end
end
end
Even if admin
is sent inside the body of the request, it isn’t accessible from params
.
Validations & Coercion
Use Cases
In our example (called “Signup”), we want to make password
a required param.
Imagine we introduce a second feature: “Invitations”.
An existing user can ask someone to join.
Because the invitee will decide a password later on, we want to persist that User
record without that value.
If we put password
validation in User
, we need to handle these two use cases with a conditional.
But in the long term this approach is painful from a maintenance perspective.
# Example of poor style for validations
class User
attribute :password, presence: { if: :password_required? }
private
def password_required?
!invited_user? && !admin_password_reset?
end
end
We can see validations as the set of rules for data correctness that we want for a specific use case.
For us, a User
can be persisted with or without a password, depending on the workflow and the route through
which the User
is persisted.
Boundaries
The second important aspect is that we use validations to prevent invalid inputs to propagate in our system. In an MVC architecture, the model layer is the farthest from the input. It’s expensive to check the data right before we create a record in the database.
If we consider correct data as a precondition before starting our workflow, we should stop unacceptable inputs as soon as possible.
Think of the following method. We don’t want to continue if the data is invalid.
def expensive_computation(argument)
return if argument.nil?
# ...
end
Usage
We can coerce the Ruby type, validate if a param is required, determine if it is within a range of values, etc..
# apps/web/controllers/signup/create.rb
module Web
module Controllers
module Signup
class Create
include Web::Action
MEGABYTE = 1024 ** 2
params do
required(:name).filled(:str?)
required(:email).filled(:str?, format?: /@/).confirmation
required(:password).filled(:str?).confirmation
required(:terms_of_service).filled(:bool?)
required(:age).filled(:int?, included_in?: 18..99)
optional(:avatar).filled(size?: 1..(MEGABYTE * 3))
end
def call(params)
if params.valid?
# ...
else
# ...
end
end
end
end
end
end
Parameter validations are delegated, under the hood, to Hanami::Validations. Please check the related documentation for a complete list of options and how to share code between validations.
Concrete Classes
The params DSL is really quick and intuitive but it has the drawback that it can be visually noisy and makes it hard to unit test.
An alternative is to extract a class and pass it as an argument to .params
.
# apps/web/controllers/signup/my_params.rb
module Web
module Controllers
module Signup
class MyParams < Web::Action::Params
MEGABYTE = 1024 ** 2
params do
required(:name).filled(:str?)
required(:email).filled(:str?, format?: /@/).confirmation
required(:password).filled(:str?).confirmation
required(:terms_of_service).filled(:bool?)
required(:age).filled(:int?, included_in?: 18..99)
optional(:avatar).filled(size?: 1..(MEGABYTE * 3)
end
end
end
end
end
# apps/web/controllers/signup/create.rb
require_relative './my_params'
module Web
module Controllers
module Signup
class Create
include Web::Action
params MyParams
def call(params)
if params.valid?
# ...
else
# ...
end
end
end
end
end
end
Inline predicates
In case there is a predicate that is needed only for the current params, you can define inline predicates:
# apps/web/controllers/books/create.rb
module Web
module Controllers
module Books
class Create
include Web::Action
params Class.new(Hanami::Action::Params) {
predicate(:cool?, message: "is not cool") do |current|
current.match(/cool/)
end
validations do
required(:book).schema do
required(:title) { filled? & str? & cool? }
end
end
}
def call(params)
if params.valid?
self.body = 'OK'
else
self.body = params.error_messages.join("\n")
end
end
end
end
end
end
Body Parsers
Rack ignores request bodies unless they come from a form submission.
If we have a JSON endpoint, the payload isn’t available in params
.
# apps/web/controllers/books/create.rb
module Web
module Controllers
module Books
class Create
include Web::Action
accept :json
def call(params)
puts params.to_h # => {}
end
end
end
end
end
$ curl http://localhost:2300/books \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"book":{"title":"Hanami"}}' \
-X POST
In order to make book payload available in params
, we should enable this feature:
# config/environment.rb
require "hanami/middleware/body_parser"
Hanami.configure do
# ...
middleware.use Hanami::Middleware::BodyParser, :json
end
Now params.get(:book, :title)
returns "Hanami"
.
In case there is no suitable body parser for your format in Hanami, it is possible to declare a new one:
# lib/foo_parser.rb
class FooParser
def mime_types
['application/foo']
end
def parse(body)
# manually parse body
end
end
and subsequently register it:
# config/environment.rb
require "hanami/middleware/body_parser"
Hanami.configure do
# ...
middleware.use Hanami::Middleware::BodyParser, FooParser.new
end