The letter A styled as Alchemists logo. lchemists
Published September 1, 2025 Updated September 18, 2026
Cover
Hanami Actions

Hanami Actions are what you use to process HTTP requests and responses. You implement an action for each HTTP verb which encourages clean separation of concerns by default. This article assumes you have familiarity with Hanami and want to delve deeper into actions as powered by the Hanami Controller gem. Let’s get started as there’s a lot of ground to cover.

Overview

At a high level, actions are commands (i.e. Command Pattern) which process HTTP requests and responses. Unlike normal commands, you don’t work with the #call method directly but via the #handle hook method which is messaged by #call. Example:

# app/actions/dashboard/show.rb

module Demo
  module Actions
    module Dashboard
      class Show < Demo::Action
        def handle *, response
          response.body = "Welcome to the dashboard."
        end
      end
    end
  end
end

The #handle method always takes two arguments: request and response. Since the above ignores the request, a single splat (*) is used instead. You’ll also notice the response object is mutated via assignment. The request object has similar behavior. All of this variable assignment gets tedious quick (more on this later) but, otherwise, is a clean way to process HTTP requests and responses.

Initialization

You can create an instance of an action like you would any Ruby object. Example:

Demo::Actions::Dashboard::Show.new

Actions accept two keyword arguments:

Generally, you don’t need to worry about initializing your actions since Hanami will do this for you automatically upon boot but this knowledge is handy for unit testing.

Dependency Injection

As mentioned above, an action is initialized with a config and contract by default. There are times when you’ll need other dependencies like a primitive, command, repository, etc. In most cases, this is as simple as using the Deps container which provides access to all of the automatically registered components within your application. Example:

include Deps[
  "aspects.devices.synchronizer",
  repository: "repositories.device"
]

With the above, we’ve injected both the synchronizer and repository dependencies for immediate use. In situations where you might need to inject the same kinds of objects, you can make them distinct by using keyword arguments to rename them upon injection. Example:

include Deps[
  device_repository: "repositories.device",
  user_repository: "repositories.user"
]

💡 To learn more about the power automatic dependency injection, see the Infusible documentation which is an advanced form of this.

If situations where you need to inject a primitive or custom dependency in general, you can always provide your own initializer. Example:

# app/actions/dashboard/show.rb

module Demo
  module Actions
    module Dashboard
      class Show < Demo::Action
        def initialize(now: Time.now.utc, **)
          @now = now
          super(**)
        end

        private

        attr_reader :now
      end
    end
  end
end

With the above, a primitive Time instance is injected. Always make sure you forward the keyword arguments up to super in order to ensure automatic dependencies and superclass dependencies are no lost. Again, more on how this works can be found in the Infusible documentation.

Configuration

All actions have access to a Hanami::Action::Config instance which can be accessed via the config object within your action. This object consists of the following attributes:

{
  handled_exceptions: {},
  formats: Hanami::Action::Config::Formats,
  default_charset: nil,
  default_headers: {},
  cookies: {},
  root_directory: Pathname,
  public_directory: String,
  before_callbacks: #<Hanami::Utils::Callbacks::Chain chain=[]>,
  after_callbacks: #<Hanami::Utils::Callbacks::Chain chain=[]>,
  contract_class: #<Class:0x0000000102e91378>
}

Having direct access to this object can be extremely handy when needing access to this information without having to inject another dependency. For example, here’s a few use cases:

  • Needing to interact with the contract_class. Sadly, this is a code smell because we shouldn’t care if we are dealing with classes or instances. In Ruby, especially any Object Oriented language, we should care about the messages, not the types.

  • Needing access to an asset via the public directory (config#public_directory).

Body Parsers

By default, Rake ignores request bodies unless they are a form submission. This means your params macro won’t process request bodies that are in a different format (like JSON which is critical for API requests). The next sections explain how to enable and customize.

JSON

To enable JSON body request parsing, update your application configuration as follows:

# config/app.rb

class Demo < Hanami::App
  config.middleware.use :body_parser, :json
end

The above will ensure you can process and validate JSON bodies using the params macro.

Custom

Should you need a custom parser for an unsupported MIME Type, you can build your own. Example:

# lib/demo/parsers/xml.rb

require "initable"

module Demo
  module Parsers
    class XML
      include Initable[types: ["application/xml"]]

      def mime_types = types

      def parse body
        # Your custom parsing implementation.
      end
    end
  end
end

# config/app.rb
class Demo < Hanami::App
  config.middleware.use :body_parser, Parsers::XML.new
end

💡 The above uses Initable to dynamically build #initialize with types injected which saves you a few lines of code while still adhering to the Barewords Pattern.

Formats

Your actions can be configured to support multiple formats (MIME Types) by updating your application configuration. Example:

# config/app.rb

module Demo
  class App < Hanami::App
    # Single format.
    config.formats.accept :json

    # Multiple formats.
    config.formats.accept :html, :json

    # Custom format.
    config.formats.register :problem_details, RFC::API::Problem::MEDIA_TYPE_JSON
  end
end

💡 The RFC API Problem gem implements RFC 9457: Problem Details for HTTP APIs. So RFC::API::Problem::MEDIA_TYPE_JSON is "application/problem+json".

Character Sets

By default, the character set for all actions is UTF-8 and available in your response Content-Type headers.

You can change this behavior by updating your configuration. Example.

# config/app.rb

module Demo
  class App < Hanami::App
    config.actions.default_charset = "ISO-8859-1"
  end
end

The above would switch your entire application to use ISO-8859-1 instead of UTF-8.

Sessions

Sessions are not enabled by default but definitely should be because you need them for cookies and Cross-Site Request Forgery (CSRF) protection. To enable, add a setting and update your configuration for all actions as follows:

# config/settings.rb

module Demo
  class Settings < Hanami::Settings
    setting :app_secret, constructor: Types::String
  end
end

# config/app.rb

module Demo
  class App < Hanami::App
    config.actions.sessions = :cookie,
                              {
                                key: "demo.session",
                                secret: settings.app_secret,
                                expire_after: 3_600  # 1 hour.
                              }
  end
end

The adapter is always the first argument when configuring sessions (in this case: cookie). Only a few adapters are provided for you which can be found by inspecting Rack::Session.constants from your console. When you see :Cookie in the output, for example, then use the lowercase symbol of the adapter (i.e. :cookie) as the first argument to config.actions.sessions. In the case where you might want to use a custom adapter, like Redis, you can do so by adding the redis-rack gem and then using :redis as the key for the adapter.

For API specific actions (or actions that don’t require a UI), you’ll want to disable forgery protection since you are not handling form submissions. To disable, create a Base API action which all actions can inherit from with the following implementation:

# demo/app/actions/api/base.rb

module Demo
  module Actions
    module API
      class Base < Hanami::Action
        protected

        def verify_csrf_token?(*) = false
      end
    end
  end
end

The #verify_csrf_token? method takes two arguments: request and response. You can ignore these arguments if you don’t need to do anything with them. As long as false is returned, then forgery protection will be disabled for all of your API actions.

Requests

A request object, as mentioned earlier, is always passed to your #handle method whether used or not. Each request instance inherits from Rack::Request which means you have full access to the Rack::Request::Helpers. The additional helpers — and specific to Hanami — are:

# Answers the request's ID.
# Example: "0a0d44b092f4b4353214964826c0fe8f"
request.id

# Answers session object. Only available when sessions are enabled.
request.session

# Answers true/false based on sessions configuration. Default: false.
request.session_enabled?

# Answers flash object. Only available when sessions are enabled.
request.flash

# Answers true/false based on given MIME Type.
request.accept?

# Answers true/false if present or not.
request.accept_header?

# Answers what is acceptable for the request.
# Example: "*/*"
request.accept

Parameters

Parameters are an instance of Hanami::Action::Params and are directly accessible via the request object which provides the following Object API:

parameters = request.params

parameters[]               # Answers value for given key.
parameters.dig             # Answers nested value.
parameters.env             # Answers request environment hash.
parameters.errors          # Answers errors hash based on validation.
parameters.error_messages  # Answers flat array of errors.
parameters.raw             # Answers orignal request parameters hash.
parameters.valid?          # Answers boolean based on if valid or not.
parameters.each            # Answers attribute iteration.
parameters.to_h            # Answers attributes hash.

Parameters can be sourced via the following:

  • The paths as defined in your routes.

  • The request’s query string.

  • The request’s body from a form or JSON body based on content type.

Due to the request parameters being hash-like, this introduces primitive obsession which will flair up your Reek smell checks. You can partially eliminate this situation by using Pattern Matching or Hash#values_at after you know your parameters are valid. Example:

# Patterm Matching
request.params => {id:, query:}
request.body = "Your ID is: #{id}. Your query is: #{query}."

# Values At
id, query = request.params.values_at :id, :query
request.body = "Your ID is: #{id}. Your query is: #{query}."

Validations

Request validation has multiple solutions. We’ll start with the basics and then get more advanced.

Inline

Inline validations are the default behavior and quickest way to get started. The following is a basic inline configuration for a create action (i.e. HTTP POST):

# demo/actions/screens/create.rb

module Demo
  module Actions
    module Screens
      class Create < Demo::Action
        params do
          required(:screen).filled(:hash) do
            required(:model_id).filled :integer
            required(:label).filled :string
          end
        end

        def handle request, response
          parameters = request.params

          if parameters.valid?
            # Create record and render updated view.
          else
            # Render view with errors.
          end
        end
      end
    end
  end
end

The params macro is what makes validation possible and breaks down as follows:

  • Requires a screen hash that must be filled.

  • Requires a model_id within the screen hash that must be filled with an integer.

  • Requires a label within the screen hash that must be filled with a string.

Afterwards, we extract the params to a local parameters variable and ask if the parameters are valid or not.

Sadly, you don’t have immediate monad support (even when monads are enabled for Dry Schema or Dry Validation) which means you are unable to use Pattern Matching for quick unwrapping of your results (more on this shortly). You can accomplish this by directly accessing the contract object which is available to all actions when defined via the params macro. Example:

def handle request, response
  case contract.call(request.params.to_h).to_monad
    in Success(parameters)
      # Create record and render updated view.
    in Failure(result)
      # Raise an error.
    else halt :unprocessable_entity
  end
end

Knowing you have access to the contract also means you can validate more than your request parameters. For example, there can be times when you want to validate your HTTP headers instead. Example:

params do
  required(:HTTP_MAC_ADDRESS).filled Types::MACAddress
  optional(:HTTP_FIRMWARE_VERSION).maybe Types::String.constrained(format: Versionaire::PATTERN)
end

case contract.call(request.env).to_monad
  in Success(headers)
    # Continue processing request.
  in Failure(result)
    # Render view with errors.
  else halt :unprocessable_entity
end

The above pattern logic is nearly identical to what was shown earlier but this time we are:

  • Requiring HTTP_MAC_ADDRESS to be filled as a MAC address (via a custom type).

  • Requiring HTTP_FIRMWARE_VERSION to be filled as a strict semantic version (via a custom Versionaire type).

Contracts

So far, we’ve only been discussing inline parameter validation as used by the params macro. A better approach is to use a Dry Validation contract especially when you need to reuse the same contract across multiple actions. You achieve this by using the contract macro instead of the params macro. To illustrate — and if we return to the original inline params — we had this implementation:

params do
  required(:screen).filled(:hash) do
    required(:model_id).filled :integer
    required(:label).filled :string
  end
end

To make this reusable, you’d define the following class:

module Demo
  module Contracts
    module Screens
      class Create < Contract
        params do
          required(:screen).filled(:hash) do
            required(:model_id).filled :integer
            required(:label).filled :string
          end
        end
      end
    end
  end
end

Then you can reference the above contract in your action as follows:

module Demo
  module Actions
    module Screens
      class Create < Demo::Action
        contract Demo::Contracts::Screens::Create

        def handle request, response
          parameters = request.params

          if parameters.valid?
            # Create record and render updated view.
          else
            # Render view with errors.
          end
        end
      end
    end
  end
end

Notice the use of the contract macro which references a reusable Dry Validation contract. The above allows you to quickly reuse these objects across multiple actions (or elsewhere in your implementation) while allowing you to further namespace as desired. A major win over inline validation when you need to DRY things up. Even better — for those that have been using schemas and contracts for years — you can build your Dry Validations atop of Dry Schemas for maximum reusability.

⚠️ Avoid injecting a dependency that is the same name as the contract macro as you’ll end up with a nil object or, worse, a double validation which can lead to strange errors. You’ll also not get any errors other than a NoMethodError when attempting to message the dependency.

Schemas

Unlike the params and contract macros, there is no schema macro so you are limited to only inline, param, and contract objects for validation purposes. The primary reason for this is that the params and contract macros both expect a class that can be initialized (i.e. must respond to .new). Schemas can’t be initialized and are designed to respond to .call instead.

While this is a bummer, you can still implement schemas which can be used by your Dry Validation contracts if you need maximum reusability.

Responses

A response object, as mentioned earlier, is always passed to your #handle method whether used or not. The response is what is sent back to the client as a HTTP response. Each response instance inherits from Rack::Request. The additional methods specific to Hanami are:

# Answers exposure for given key.
response.[]

# Set exposure for given key and value.
response.[]=

# Sets body.
response.body=

# Sets status. Default: 200.
response.status=

# Renders view.
response.render

# Sets format.
response.format=

# Answers format.
response.format

# Answers true/false based on sessions configuration. Default: false.
response.session_enabled?

# Answers session object. Only available when sessions are enabled.
response.session

# Answers flash object. Only available when sessions are enabled.
response.flash

# Answers cookies.
response.cookies

# Redirects to desire path.
response.redirect_to

# Sends file for given path in public directory.
response.send_file

# Sends file for given path from anywhere in the file system.
response.unsafe_send_file

# Sets the freshness policy the `Cache-Control` header.
response.cache_control

# Sets the `Expires` header and `Cache-Control` or `max-age`.
response.expires

# Sets the `etag` and/or `last_modified` headers. Halts with a `304 Not Modified` if the request is still fresh according to the `IfNoneMatch` and `IfModifiedSince` request headers.
response.fresh

# Answers whether response is renderable or not.
response.renderable?

# Answers whether response is redirectable or not.
response.allow_redirect?

# Answers whether response is a HTTP HEAD response or not.
response.head?

For example, here’s a simple JSON response:

# app/actions/demo/screens/create.rb

module Demo
  module Actions
    module Screens
      class Create < Demo::Action
        def handle request, response
          response.headers["Custom"] = "demo"

          response.body = {
            data: {
              id: screen.id,
              label: screen.label,
              uri: screen.uri,
              created_at: screen.created_at,
              updated_at: screen.updated_at
            }
          }

          response.format = :json
          response.status = 201
        end
      end
    end
  end
end

Headers

Headers, as mentioned earlier, are available via the Rack::Response superclass which means you can update them through mutation:

response.headers["Content-Type"] = "text/event-stream"

This is especially useful when updating a htmx response via the HTMX gem where we need to update the browser URL through htmx’s push URL:

htmx.response! response.headers, push_url: routes.path(:playlists, query:)

Views

The convention, when rendering views, is to use RESTful terminology. This means a default view dependency is automatically injected and available based on the RESTful name of your action. The mapping is as follows:

  • IndexIndex

  • ShowShow

  • NewNew

  • CreateNew

  • EditEdit

  • UpdateEdit

  • Patchnil

  • Deletenil

You can override or inject additional views as needed but the above saves time when not needing to customize further.

The context of each view provides the following methods which you can access inside your templates, parts, and scopes:

  • request: The request object.

  • session: The session (if enabled).

  • flash: The flash (if session is enabled).

  • csrf_token: The CRSR token.

In addition to these conventions, an action can render a view implicitly or explicitly. The following breaks down each.

Implicit

Your action will render the default view based on the name of your action which means you can do this:

# demo/actions/articles/show.rb

module Actions
  module Articles
    class Show < Demo::Action
    end
  end
end

The above will look for the associated Show view (and show.html.erb template) and automatically render the view layer for you. This is made possible through convention via the following structure:

# Action
# demo/actions/articles/show.rb

# View
# demo/views/articles/show.rb

# Template
# demo/templates/articles/show.html.erb

Should you not desire this implicit behavior, you can explicitly define behavior as explained below.

Explicit

The response#render method is how you render your view. The first parameter must always be the view. Templates or partials are not allowed. Here’s the method signature:

response.render view, **attributes

For example, you can change the layout:

def handle request, response
  response.render view, layout: "alternate"
end

If you don’t want to use the default view, you can change this behavior by injecting a different view dependency:

# demo/actions/screens/show.rb

module Actions
  module Screens
    class Show < Demo::Action
      include Deps[view: "views.screens.secondary"]
    end
  end
end

With the above, the default view is altered to use the secondary view. Due to the power of dependency injection, you can bring in other views which is handy for rendering errors or the reusing the views of other actions.

For a much deeper dive into the view layer, see my earlier article on Hanami Views to learn more.

Automatic Disablement

Should you want to disable automatic rendering, you can do this by implementing the following method:

def auto_render?(*) = false

💡 A response object is passed to this method if you need more sophisticated disablement.

Statuses

By default, a response uses a HTTP 200 status code but you can use a symbol as well. Example:

def handle *, response
  # With code.
  response.status = 201

  # With symbol.
  response.status = :created
end

The full list of code and symbols which can be obtained via your Hanami console through the Hanami::Http::Status::SYMBOLS constant. This constant is, essentially, a variant of the original Rack::Utils::HTTP_STATUS_CODES constant.

💡 Speaking of HTTP status codes, you can use the Pennyworth gem to quickly copy any HTTP status code’s label and code to your clipboard or quickly jump to documentation in your browser via the Alfred https macro.

Flashes

Flash messaging requires Cookies to be enable before using. Once enabled, each response can have a flash message which renders upon redirect (delayed) or immediately for the current action. Example:

# Redirect.
response.flash[:notice] = "Demo."
response.redirect_to routes.path(:articles)

# Immediate.
response.flash.now[:alert] = "Demo."

The rendering of this information can be handled via your Hanami Views. Example:

<% if flash[:alert] %>
  <span class="alert"><%= flash[:alert] %></span>
<% end %>

<% if flash[:notice] %>
  <span class="notice"><%= flash[:notice] %></span>
<% end %>

Exposures

Exposures provide a convenient way to supply objects to your Hanami Views via the response object for rendering within your templates. By default, if you wanted to find an article and expose it via your view, you would use the following implementation:

class Show < Hanami::Action
  def handle request, response
    article = repository.find request.params[:id]
    response.render view, article:
  end
end

The above works fine and is the default pattern but you can assign the article to your response instead. For instance, the following is identical, in behavior, to the above:

class Show < Hanami::Action
  def handle request, response
    response[:article] = repository.find request.params[:id]
    response.render view
  end
end

When using the response object like this, you can also access these same objects when interacting with your action directly. Example:

action = Show.new Hash.new
response = action.call id: 1
article = response[:article]

article.class  # Article
article.id     # 1

With the above, we find an article record and then expose it as such. Later, after we call the action, we obtain the article exposed in the response. By default, when not using exposures, only params and format are exposed (i.e. response.exposures).

Caching

HTTP caching is managed via the Cache-Control header of your response object. Multiple methods are available and each is described below.

Cache Control

The #cache_control method allows you to set multiple directives via positional and keyword arguments. The following is only a small example of what you can do:

  • Positional (splatted as symbols)

    • :public: Indicates the response can be stored in a shared cache. Responses for requests with Authorization header fields must not be stored in a shared cache because this directive will cause data to be stored in a shared cache.

    • :private: Indicates the response can only be stored in a private cache (i.e. local browser caches). Use this directive for user-personalized content especially for responses received after login and for cookie managed sessions. Forgetting to use this directive with personalized content means data will be stored in shared cache and end up being reused for multiple users which can cause personal information to leak.

    • :immutable: Indicates the response will not be updated while it’s fresh.

    • :no_cache: Indicates the response can be stored in caches but the response must be validated with the origin server before each reuse even when the cache is disconnected from the origin server.

    • :no_store: Indicates caches of any kind (private or shared) should not store this response.

    • :must_understand: Indicates a cache should store the response only if it understands the requirements for caching based on status code. This should be paired with :no_store for fallback behavior.

    • :must_validate: Indicates the response can be stored in caches and can be reused while fresh. If the response becomes stale, it must be validated with the origin server before reuse.

    • :proxy_revalidate: The equivalent of must-revalidate but for shared caches only.

  • Keywords

    • max_age: The number of seconds the response remains fresh.

    • s_maxage: The number of seconds the response remains fresh in a shared cache. This directive is ignored by private caches and overrides max-age (if specified) or the Expires header for shared caches (if present).

    • min_fresh: Indicates the client allows a stored response that is fresh for a minimum number of seconds.

    • max_stale: Indicates the client allows a stored response that is stale within a maximum number of seconds. If no value is specified, the client will accept a stale response of any age.

    • stale_while_revalidate: Indicates the cache could reuse a stale response while revalidating via the cache. Requires a duration in seconds for the value.

Given the above, this means you can set the Cache-Control header of your response as public with a max-age of 300 seconds (5 minutes).

def handle *, response
  response.cache_control :public, max_age: 300
end

Expires

The Expires response header sets the date and time, in seconds, for when the response is expired. Prefer the Cache-Control over the Expires header since the former is preferred by modern browsers. The #expires method accepts the same positional and keyword arguments after the seconds argument as documented above for the #cache_control method:

def handle *, response
  response.expires 30, :public, max_age: 300
end

Fresh

Use the #fresh method to process Conditional Requests which is useful for validating cached content.

def handle request, response
  article = repository.find request.params[:id]

  # Works with `If-Modified-Since` header.
  response.fresh last_modified: article.updated_at

  # Works with `If-None-Match` header.
  response.fresh etag: "#{article.id}-#{article.updated_at}"
end

There is a lot you can do with this method so see the MDN Documentation for further details and use cases.

Redirects

You can redirect your responses multiple ways. Here’s a few examples:

def handle *, response
  # With string.
  response.redirect_to "/dashboard"

  # With named path.
  response.redirect_to routes.path(:dashboard)

  # To external resource.
  response.redirect_to "https://faq.example.io", status: 301
end

The default status is 302 but you can customize to use 301 as shown above.

Rack

As with most web applications, Hanami is built atop Rack which means you have full access to the Rack layer should you need it. The following breaks down the different way in which you can leverage Rack in your own application.

Actions

All actions wrap the Rack request and response objects which you can interact with as follows:

def handle(request, response)
  parameters = request.params

  request.env["REQUEST_METHOD"]        # GET
  request.get_header "REQUEST_METHOD"  # GET

  response.headers["HX-Push-Url"] = routes.path(:albums, query: parameters[:query])
  # {"HX-Push-Url" => "/albums?query=landcapes"}
end

As you can see from the above, there are multiple ways you can obtain header information (in the case of the request) or update the Rack environment (in the case of the response). This also means, you can do nice things, as provided with the HTMX gem, where you can have HTMX manipulate the Rack headers for you:

htmx.response! response.headers, push_url: routes.path(:albums, query: parameters[:query])
# {"HX-Push-Url" => "/albums?query=landcapes"}

Middleware

Rack middleware is supported via three different layers: application, router, and Rackup. The following details each.

Application

The application layer is where you define common middleware that is meant to used by your entire application. For the example, the following ensures Rack is configured to protect you from bad actors, ensure your responses are compressed, and that you have the JSON body parse loaded in order to parse JSON request parameters.

# config/app.rb

module Demo
  class App < Hanami::App
    config.middleware.use Rack::Attack
    config.middleware.use Rack::Deflater
    config.middleware.use :body_parser, :json
  end
end

💡 Hanamismith will provide some of this for you when building new Hanami applications.

The order in which you define your middleware matters. With the above, Rack::Attack will be loaded first while the body parser is last.

You can also use before and after keyword arguments to change the order in which your middleware is loaded. Example:

# config/app.rb

module Demo
  class App < Hanami::App
    config.middleware.use Rack::Attack, after: :body_parser
    config.middleware.use Rack::Deflater, before: Rack::Auth::Basic
    config.middleware.use :body_parser, :json
  end
end

The above is silly, and contrived, but shows how these keyword arguments work. Avoid using them, as shown above, for manipulating the order of middleware you control, though. These keywords are best used when wanting to control where your middleware is loaded due to middleware loaded from a gem that isn’t part of your core application.

Router

The router is where you define middleware for dealing with specific routes. The middleware defined in your routes will load after any middleware defined in your application configuration (as discussed earlier).

# config/routes.rb

module Demo
  class Routes < Hanami::Routes
    use Aspects::Screens::Designer::Middleware, pattern: %r(/preview/(?<name>.+))
  end
end

This means you have a lot more control over where your middleware is used. For example, when needing to authorize sensitive endpoints such as your admin portal:

# config/routes.rb

module Demo
  class Routes < Hanami::Routes
    slice :admin, at: "/admin" do
      use Rack::Auth::Basic

      get "/users", to: "users.index"
    end
  end
end

⚠️ Be aware that middleware that requires access to the database will fail to load because the database provider will not be loaded in time. To fix, refactor your middleware within a slice and then define that slice within your routes.

Rackup

Finally, you can load middleware via Rackup by using your middleware before the Hanami application is run. Any middleware defined here will not be available to your Hanami application, though. Example:

# config.ru

require "hanami/boot"

use Rack::Static, root: "public", urls: ["/.well-known/security.txt", "/public"]

run Hanami.app

Debugging

You can always view your middleware from the command line. Example:

bundle exec hanami middleware
# /    Dry::Monitor::Rack::Middleware (instance)
# /    Hanami::Middleware::RenderErrors
# /    Hanami::Webconsole::Middleware
# /    Rack::MethodOverride
# /    Hanami::Middleware::Assets
# /    Rack::Attack
# /    Rack::Deflater
# /    Hanami::Middleware::BodyParser
# /    Rack::Static

The above not only shows you what middleware is in use by your application but also the order (top to bottom) in which they are processed.

Formats

Hanami supports many MIME Types which can be found via the Hanami::Action::Mime::TYPES within your console. Using one (or many) of these MIME Types determine the request and response formats for your actions which ensures:

  • Your action will accept only what’s defined in the Accept or Content-Type header.

  • Your action will respond with the appropriate Content-Type header.

  • Your action will automatically parse the request body based on format (if applicable).

Formats can be configured per action and/or per response. Example:

class Index < Demo::Action
  # Per action.
  format :json

  # Per response.
  def handle *, response
    # With a symbol.
    response.format = :json

    # With a string.
    response.format = "application/json"
  end
end

Callbacks

All actions support before and after callbacks which take a symbol or a block. Example:

# app/actions/dashboard/show.rb

module Demo
  module Actions
    module Dashboard
      class Show < Demo::Action
        before :validate
        after { |request, response| response.body = response.body.strip }

        private

        def validate request, response
          parameters = request.params
          halt 422, parameters.errors.to_h unless parameters.valid?
        end
      end
    end
  end
end

As you can see, the before callback uses a symbol to message the validate method to process the request and response. The after callback uses a block, instead, to clean up the response as a final step.

Cookies

Cookies are configured via your application configuration and then used via your action’s request and response objects. First, start by configuring your cookies.

Configuration

Cookies can be configured via your application configuration. Example:

# config/app.rb

module Demo
  class App < Hanami::App
    config.actions.cookies = {
      domain: "example.io",
      path: "/demo",
      max_age: 300,
      secure: true,
      httponly: true
    }
  end
end

Here’s a breakdown of the above attributes:

  • domain: Your domain. Default: nil.

  • path: The relative route path. Default: nil.

  • max_age: The duration, in seconds, before the cookie expires. Default: nil.

  • secure: Enables/disables use of secure cookies. Default: true.

  • httponly: Ensures client-side JavaScript can’t access the cookies to prevent cross-site scripting attacks. Default: true.

Actions

You can manage cookies via the request and response objects of your action’s handle method. Example:

def handle request, response
  # Read from request.
  request.cookies["demo_in"]  # "Incoming demonstration."

  # Write to response.
  response.cookies["demo_out"] = "Outgoing demonstration."
end

You’re not limited to reads/writes as you can change the cookie’s configuration by using a hash. Example:

def handle request, response
  response.cookies["demo_out"] = {
    value: "Outgoing demonstration.",
    max_age: 21_600  # Six hours.
  }
end

When using a hash, you must use the value key to set your value.

Removing

To remove a cookie, set it to nil:

def handle *, response
  response.cookies["demo"] = nil
end

Sadly, you can’t use response.cookies.delete "demo" as the Hanami::Action::CookieJar object doesn’t account for this behavior.

Disabling

To disable cookies entirely, update your application configuration as follows:

# config/app.rb

module Demo
  class App < Hanami::App
    config.actions.cookies = nil
  end
end

Exceptions

Exceptions can be handled multiple ways. The is first by configuring behavior for your action which maps classes (as strings) to HTTP status codes. Example:

class Show < Demo::Action
  config.handle_exception "CustomExceptionOne" => 500, "CustomExceptionTwo" => 501
end

The above allows you to map custom errors to appropriate status codes. For more fine grained control, you can leverage the handle_exception macro at the top of your action. Each handler must be able to accept the request, response, and exception as positional arguments. Example:

class Show < Demo::Action
  handle_exception NotFound => :not_found

  def handle(request, response)
    if request.params.valid?
      response.body = "Demo"
    else
      fail NotFound, "Invalid parameters"
    end
  end

  private

  def not_found(*, response, exception)
    response.body = "Danger: #{exception.message}"
    response.status = :internal_server_error
  end
end

You can also use halt to throw exceptions along with different message formats (i.e. JSON, plain text, etc). Example:

# With symbol.
halt :not_found, {errors: {article: "Missing"}}.to_json

# With integer.
halt 404, {errors: {article: "Missing"}}.to_json

# With plain text.
halt :not_found, "The article is missing."

If you find yourself repeating the same logic for handling exceptions across multiple actions, you can move this logic up to your superclass to define exception handling once and then reuse across multiple actions.

Lastly, you can also account for different environments where you might want to blow up quickly for debugging purposes:

def handle_error request, response, exception
  raise exception if Hanami.env? :development

  response.body = "Opps, something is amiss."
  response.status = 500
end

Inheritance

In general, prefer composition over inheritance. That said, there are situations where you might want to extract common behavior to a superclass from which you can inherit common functionality as protected methods. The benefits to subclassing, in these situations, are:

  • Access to injected dependencies.

  • The superclass' configuration.

  • Callbacks (i.e. before and after).

  • Validations (i.e. params, schemas, and contracts).

For example, consider the following:

# demo/app/action.rb

module Demo
  class Action < Hanami::Action
    include Deps[:authenticator]

    format :json

    before :authenticate_user!

    protected

    def authenticate_user!(request, response)
      halt 401 unless authenticator.valid? request.headers["X-API-Token"]
    end
  end
end

Due to behavior being defined on your application’s main action, this means all actions would have access to the authenticator, be formatted as JSON, and automatically authenticate before processing requests. You’re not limited to this kind of global behavior but can specialize per namespace. For example, you might only need specialized functionality for your articles namespace where you can have a Base action from which all other actions inherit from. Example:

# demo/app/actions/articles/base.rb

module Demo
  module Actions
    module Articles
      class Base < Hanami::Action
        include Deps[:htmx]
      end
    end
  end
end

# demo/app/actions/articles/index.rb

module Demo
  module Actions
    module Articles
      class Index < Base
      end
    end
  end
end

The above defines a Base action which injects HTMX dependency. Then, within the same Articles namespace, the Index inherits from Base to pick up this dependency. The same can be done for your API namespace. Example:

# demo/app/actions/api/base.rb

require "rfc/api/problem"

module Demo
  module Actions
    module API
      class Base < Hanami::Action
        format :json

        def initialize(problem: RFC::API::Problem, **)
          @problem = problem
          super(**)
        end

        protected

        attr_reader :problem
      end
    end
  end
end

# demo/app/actions/api/devices/index.rb

module Demo
  module Actions
    module API
      module Devices
        class Index < Base
        end
      end
    end
  end
end

As before, we have a Base class defined within the API namespace. Then the Devices::Index actions inherits from Base to use the RFC API Problem dependency and ensure all responses are in JSON format.

All of this works great if your inheritance hierarchy is clean and doesn’t violate the Liskov Substitution Principle (LSP) or the Interface Segregation Principle (ISP) of SOLID design. For situations in which you need to extract common behavior that needs to be used sparingly across different namespaces, you can define the behavior your need through the use of multiple inheritance:

# app/actions/authenticatable.rb

module Demo
  module Actions
    module Authenticatable
      def self.included(descendant) = descendant.before :authenticate!

      private

      def authenticate! request, response
        # Add implementation details here.
      end
    end
  end
end

module Demo
  module Actions
    module Articles
      class Update < Demo::Action
        include Authenticatable
      end
    end
  end
end

With the above, the Authenticatable module leverages good module design as written about before (see Modules) which is then inherited in the Articles::Update action but could be inherited via any action that might need this behavior regardless of namespace which can be the kind of flexibility you need when standard inheritance is too broad or narrow.

Refinements

While actions provide a lot of functionality out of the box, they can still be limiting or tedious (as alluded to earlier). For situation in which you wish Hanami would provide the syntactic sugar you desire, then Refinements are a good solution. One such example, is having to write multiple lines of code to set the values of your response object:

def handle *, response
  response.body = "Created."
  response.format = :json
  response.status = 201
end

The above is too many lines of code, so you can use a refinement that collapses this down as follows:

using Refines::Actions::Response

def handle *, response
  response.with body: "Created", format: :json, status: 201
end

Much better! This is only one example of where you can improve Hanami Actions.

💡 The above refinement is provided for you via Hanamismith.

Tests

When testing your actions, there are two major categories to think about: unit and full stack (i.e. requests and features). In general, you want to write full stack specs to ensure your entire stack is tested but unit specs are handy when you need quick specs that test behavior that would be hard in a full stack spec.

Unit

As mentioned above, unit specs are your fastest specs. They are also quick to setup because each action is a miniature Rake application which means you can wrap your action in a mock Rack request to make GET, POST, PUT, DELETE, and other requests. That said, the HTTP verb should always match the type of action you are testing. This allows you to test HTTP headers and parameters. Example:

response = Rack::MockRequest.new(action).get "",
                                             "HTTP_HX_TRIGGER" => "search",
                                             params: {query: "test"}

With the above, a HTTP GET request is made. The path is an empty string because you’re not going through the router when writing unit specs so you can always leave this blank. The first set of keyword arguments are always your HTTP headers followed by any parameters. In this example, we are simulating a htmx search request with a query parameter. Then we can query the response’s body (i.e. respnose.body) to check if our search results are as expected.

Here’s another example where we are using htmx to create a new screen with a model association and a few required attributes (i.e. label and name). Like the earlier spec, we want to test if the request is a htmx request or a standard web request (should htmx not be enabled):

# demo/spec/app/actions/screens/create_spec.rb

RSpec.describe Demo::Actions::Screens::Create, :db do
  subject(:action) { described_class.new }

  describe "#call" do
    let(:model) { Factory[:model] }

    let :params do
      {
        screen: {
          model_id: model.id,
          label: "Test",
          name: "test"
        }
      }
    end

    it "renders default response" do
      response = Rack::MockRequest.new(action).post("", params:)
      expect(response.body).to include("<!DOCTYPE html>")
    end

    it "renders htmx response" do
      response = Rack::MockRequest.new(action)
                                  .post("", "HTTP_HX_REQUEST" => "true", params:)

      expect(response.body).to have_htmx_title("Screens")
    end
  end
end

As you can see with the above, we only test if htmx is used or not. In the first spec, we want to see the full HTML document is returned when not making an htmx request. In the second spec, htmx is used because the HTTP_HX_REQUEST request header is included so we want to see that the HTMX document that is returned is an HTML fragment which used a custom have_htmx_title RSpec matcher to see if the HTML title element is supplied so htmx can update the page title in your browser tab.

These are a few examples of what you can do with unit tests that would otherwise be awkward or too slow to deal with at the feature spec level. You still need to write a full feature spec since you’re not testing the router, though.

You can also call your action directly if you don’t need header/parameter customization. Example:

# demo/spec/app/actions/screens/create_spec.rb

RSpec.describe Demo::Actions::Screens::Index do
  subject(:action) { described_class.new }

  describe "#call" do
    it "answers success" do
      expect(action.call({})).to be_successful
    end
  end
end

This has limited use because you can’t pass in header or parameters since #call only accepts a configuration and/or contract object which is why an empty hash is used for the configuration in this example.

Requests

Requests specs are for testing the full stack of your API. These are slower than unit specs but faster than feature specs since you don’t need the web stack. You’re only working with raw JSON, XML, etc. Here’s an example of creating a screen, as shown earlier, but via the API:

# demo/spec/requests/screens_spec.rb

require "hanami_helper"

RSpec.describe "/api/screens", :db do
  let(:model) { Factory[:model] }

  it "creates image from HTML" do
    post routes.path(:api_screen_create),
         {screen: {model_id: model.id, label: "Test", name: "test", content: "<p>Test</p>"}}.to_json,
         "CONTENT_TYPE" => "application/json"

    expect(json_payload).to match(
      data: {
        model_id: model.id,
        id: kind_of(Integer),
        label: "Test",
        name: "test",
        filename: "test.png",
        uri: %r(memory://\h{32}.png),
        mime_type: "image/png",
        bit_depth: 1,
        size: kind_of(Integer),
        width: 800,
        height: 480,
        created_at: match_rfc_3339,
        updated_at: match_rfc_3339
      }
    )
  end
end

In this case we are making an HTTP POST request to the /api/screens endpoint via the Hanami named path: routes.path(:api_screen_create). The screen JSON payload is used to create a new record. The last keyword argument is the CONTENT_TYPE HTTP header which is used to set the correct format. Finally, we expect that our JSON response (symbolized into a hash via the json_playload helper) so we can check our attributes match as expected. Fuzzy matching is used in this case because you want to check the shape of the response.

Features

Features specs allow you to test the full stack of your UI (including JavaScript if enabled via the js meta key). These are your slowest specs so you want to write them in a manner that is efficient while ensuring critical user workflows are well tested. You’ll also want to group several expectation within a single test in order improve spec performance despite being one of many RSpec Antipatterns which is why aggregate_failures is used to capture any/all errors as one for faster debugging. The antipattern can’t be avoided since constant setup and teardown can be slow.

Here’s an example of testing the creation, editing, and deleting of a screen via the UI:

# demo/spec/features/screens_spec.rb

require "hanami_helper"

RSpec.describe "Screens", :db do
  it "creates, edits, and deletes screen", :aggregate_failures, :js do
    model = Factory[:model]

    visit routes.path(:screens)
    click_link "New"
    select model.label, from: "screen[model_id]"
    fill_in "screen[label]", with: "Test"
    click_button "Save"

    expect(page).to have_content("must be filled")

    fill_in "screen[name]", with: "test"
    click_button "Save"

    expect(page).to have_content("Test")

    click_link "Edit"
    fill_in "screen[label]", with: nil
    click_button "Save"

    expect(page).to have_content("must be filled")

    fill_in "screen[label]", with: "Test II"
    click_button "Save"

    expect(page).to have_content("Test II")

    accept_prompt { click_link "Delete" }

    expect(page).to have_no_content(screen.label)
  end
end

As you can see, this is a fairly standard Capybara spec. The only thing that makes this unique to Hanami is that we are using a Factory to build a model dependency and then using a named route (routes.path(:screens)). Otherwise, everything is standard Capybara.

Conclusion

There’s a lot you can do with actions and hopefully this makes implementing and writing specifications for them easier for you. They are definitely not perfect and could use a lot more polish in terms of developer experience but, otherwise, are a clean way to in which to build a web application by mapping a single route to a single action. Enjoy!