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:
-
config: AHanami::Action::Configobject which, ultimately, is a variant of Dry Configuration. -
contract: A Dry Schema or Dry Validation object for which to validate your request parameters. Default:nil.
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
screenhash that must be filled. -
Requires a
model_idwithin thescreenhash that must be filled with an integer. -
Requires a
labelwithin thescreenhash 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_ADDRESSto be filled as a MAC address (via a custom type). -
Requiring
HTTP_FIRMWARE_VERSIONto 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:
-
Index→Index -
Show→Show -
New→New -
Create→New -
Edit→Edit -
Update→Edit -
Patch→nil -
Delete→nil
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 withAuthorizationheader 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_storefor 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 ofmust-revalidatebut 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 overridesmax-age(if specified) or theExpiresheader 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
AcceptorContent-Typeheader. -
Your action will respond with the appropriate
Content-Typeheader. -
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!
