Parameters for a request come from a number of sources:
- as specified in the route that has matched the request (e.g.
/books/:id
) - the request’s query string (e.g.
/books?page=2&per_page=10
) - the request’s body (for example the JSON-formatted body of a
POST
request of Content typeapplication/json
).
def handle(request, response)
# GET /books/1
request.params[:id] # => "1"
# GET /books?category=history&page=2
request.params[:category] # => "history"
request.params[:page] # => "2"
# POST /books '{"title": "request body", "author":"json"}', Content-Type application/json
request.params[:title] # => "request body"
request.params[:author] #=> "json"
end
Accessing parameters
Request parameters are referenced by symbols.
request.params[:q]
request.params[:book][:title]
Nested parameters can be safely accessed via the #dig
method on the params
object. This method accepts a list of symbols, where each symbol represents a level in our nested structure. If the :book
param above is missing from the request, using #dig
avoids a NoMethodError
when attempting to access :title
.
request.params.dig(:book, :title) # => "Hanami"
request.params.dig(:deeply, :nested, :param) # => nil instead of NoMethodError
The parameters associated with a web request are untrusted input.
In Hanami actions, params can be validated using a schema specified using a params
block.
This validation serves several purposes, including allowlisting (ensuring that only allowable params are extracted from a request) and coercion (converting string parameters to boolean, integer, time and other types).
Let’s take a look at a books index action that accepts two parameters, page
and per_page
.
# app/actions/books/index.rb
module Bookshelf
module Actions
module Books
class Index < Bookshelf::Action
params do
optional(:page).value(:integer)
optional(:per_page).value(:integer)
end
def handle(request, response)
request.params[:page]
request.params[:per_page]
end
end
end
end
end
The schema in the params block specifies the following:
page
andper_page
are both optional parameters- if
page
is present, it must be an integer - if
per_page
is present, it must be an integer
With this schema in place, a request with a query string of /books?page=1&per_page=10
will result in:
request.params[:page] # => 1
request.params[:per_page] # => 10
Additional rules can be added to apply further constraints. The following params block specifies that, when present, page
and per_page
must be greater than or equal to 1, and also that per_page
must be less than or equal to 100.
Importantly, now that our params schema is doing more than just type coercion, we need to explicitly check for and handle our parameters being invalid.
The #valid?
method on the params allows the action to check the parameters in order halt and return a 422 Unprocessable
response.
# app/actions/books/index.rb
module Bookshelf
module Actions
module Books
params do
optional(:page).value(:integer, gte?: 1)
optional(:per_page).value(:integer, gte?: 1, lteq?: 100)
end
def handle(request, response)
# At this point, we know the params are valid
request.params[:page]
request.params[:per_page]
end
end
end
end
end
Here’s a further example, this time for an action to create a user.
# app/actions/users/create.rb
module Bookshelf
module Actions
module Users
class Create < Bookshelf::Action
params do
required(:email).filled(:string)
required(:password).filled(:string)
required(:address).hash do
required(:street).filled(:string)
required(:country).filled(:string)
end
end
def handle(request, response)
halt 422 unless request.params.valid?
request.params[:email] # => "alice@example.org"
request.params[:password] # => "secret"
request.params[:address][:country] # => "Italy"
request.params[:admin] # => nil
end
end
end
end
end
The params
block in this action specifies that:
email
,password
andaddress
parameters are required to be present.address
hasstreet
andcountry
as nested parameters, which are also required.email
,password
,street
andcountry
must be filled (non-blank) strings.
The errors associated with a failed parameter validation are available via request.params.errors
.
Assuming that the users create action was part of a JSON API, we could render these errors by passing a body when calling halt
:
halt 422, {errors: request.params.errors}.to_json unless request.params.valid?
For an empty POST
request with an empty address object, this action would render:
{
"errors": {
"email": [
"is missing"
],
"password": [
"is missing"
],
"address": {
"street": [
"is missing"
"country": [
"is missing"
]
}
}
}
Action validations use the dry-validation gem, which provides a powerful DSL for defining schemas.
Using concrete classes
In addition to specifying parameter validations “inline” in a params
block, actions can also hand over their validation responsibilities to a separate class.
This makes action validations reusable and easier to test independently of the action.
For example:
module Bookshelf
module Actions
module Users
module Params
class Create < Hanami::Action::Params
params do
required(:email).filled(:string)
required(:password).filled(:string)
required(:address).hash do
required(:street).filled(:string)
required(:country).filled(:string)
end
end
end
end
end
end
end
Validating parameters in actions is useful for validating parameter structure, performing parameter coercion and type validations.
More complex domain-specific validations, or validations concerned with things such as uniqueness, however, are usually better performed at layers deeper than your HTTP actions.
For example, verifying that an email address has been provided is something an action parameter validation should reasonably do, but checking that a user with that email address doesn’t already exist is unlikely to be a good responsibility for an HTTP action to have. That validation might instead be performed by a create user operation, which can perform a check against a user store.
Body parsers
Rack ignores request bodies unless they come from a form submission. This means that, if we have a JSON endpoint, the payload isn’t automatically available in params
.
# app/actions/users/create.rb
module Bookshelf
module Actions
module Users
class Create < Bookshelf::Action
accept :json
def handle(request, response)
request.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
To enable params from JSON request bodies, use Hanami’s body parsing middleware via your app config:
# config/app.rb
class App < Hanami::App
config.middleware.use :body_parser, :json
end
Now params.dig(:book, :title)
will return "Hanami"
.
# lib/foo_parser.rb
class FooParser
def mime_types
['application/foo']
end
def parse(body)
# manually parse body
end
end
# config/app.rb
class App < Hanami::App
config.middleware.use :body_parser, FooParser.new
end