Matthew Lang avatar

Hanami Actions & Validation

Hanami validates incoming params with a schema/contract before your action body ever runs — invalid input never reaches your domain logic, and there’s no manual params.require(:x).permit(:y) juggling.

Inline params schema

module MyApp
  module Actions
    module Posts
      class Create < MyApp::Action
        params do
          required(:title).filled(:string)
          optional(:body).maybe(:string)
          required(:status).value(included_in?: %w[draft published])
        end

        def handle(request, response)
          halt 422 unless request.params.valid?

          post = post_repo.create(request.params.to_h)
          response.redirect_to "/posts/#{post.id}"
        end
      end
    end
  end
end

The schema DSL is dry-schema under the hood — required/optional, filled/maybe, type checks (:string, :integer), and predicate checks (included_in?, gt?, etc.) all compose.

Standalone contracts

For validation logic reused across multiple actions (or that needs custom rules beyond shape checking), pull it into its own class:

module MyApp
  module Actions
    module Posts
      class CreateContract < MyApp::Action::Contract
        params do
          required(:title).filled(:string, max_size?: 200)
        end

        rule(:title) do
          key.failure("must be unique") if MyApp::Container["repositories.post_repo"].title_taken?(value)
        end
      end
    end
  end
end

rule blocks handle validation that needs to reach outside the shape of the input itself — DB uniqueness checks, cross-field consistency, and so on.

Accessing validated params

request.params[:title]      # raw or coerced value
request.params.to_h         # whole validated, symbol-keyed hash
request.params.valid?       # true/false
request.params.errors       # error messages, keyed by field

Only fields declared in the schema are permitted through — anything else submitted is silently dropped, the same protection strong_parameters gives you in Rails, but declared once per action instead of inline per controller action.

Halting the request

halt 404                          # not found, empty body
halt 422, { errors: request.params.errors.to_h }.to_json
halt 401 unless authenticated?

halt immediately stops the action and returns the given status — no need for an explicit return after it.

Callbacks

class Show < MyApp::Action
  before :require_authentication!

  private

  def require_authentication!(request, response)
    halt 401 unless request.session[:user_id]
  end
end

before/after callbacks run in declaration order, same idea as Rails’ before_action, but scoped to a single action class rather than shared implicitly across a whole controller.

Rendering an error response

def handle(request, response)
  result = post_repo.create(request.params.to_h)
  response.render(view, post: result)
rescue MyApp::ValidationError => e
  response.status = 422
  response.render(error_view, errors: e.errors)
end

Actions are plain Ruby objects, so ordinary rescue works exactly as you’d expect — no separate rescue_from registry to keep in sync with the rest of the codebase.