From 03db6eb1ec6e440294ced603d8d147cf9b18268a Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 15:36:21 -0600 Subject: [PATCH 01/26] Switch to request.env --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b69bdfbe..2ef5045a 100644 --- a/README.md +++ b/README.md @@ -773,7 +773,7 @@ You can check if the requested MIME type is accepted by the client. class Show < Hanami::Action def handle(request, response) # ... - # @_env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9" + # request.env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9" request.accept?("text/html") # => true request.accept?("application/xml") # => true @@ -781,7 +781,7 @@ class Show < Hanami::Action response.format # :html - # @_env["HTTP_ACCEPT"] # => "*/*" + # request.env["HTTP_ACCEPT"] # => "*/*" request.accept?("text/html") # => true request.accept?("application/xml") # => true From b93754826464fcea27f8dee88c54492953f9aa7d Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 22:37:03 -0600 Subject: [PATCH 02/26] Rename Repository -> Repo, to fit convention from hanami-db --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2ef5045a..7eba7d6f 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ They are the endpoints that respond to incoming HTTP requests. ```ruby class Show < Hanami::Action def handle(request, response) - response[:article] = ArticleRepository.new.find(request.params[:id]) + response[:article] = ArticleRepo.new.find(request.params[:id]) end end ``` @@ -68,28 +68,28 @@ To provide custom behaviour when your actions are being called, you can implemen **An action is an object** and **you have full control over it**. In other words, you have the freedom to instantiate, inject dependencies and test it, both at the unit and integration level. -In the example below, the default repository is `ArticleRepository`. During a unit test we can inject a stubbed version, and invoke `#call` with the params. -__We're avoiding HTTP calls__, we're also going to avoid hitting the database (it depends on the stubbed repository), __we're just dealing with message passing__. +In the example below, the default repo is `ArticleRepo`. During a unit test we can inject a stubbed version, and invoke `#call` with the params. +__We're avoiding HTTP calls__, we're also going to avoid hitting the database (it depends on the stubbed repo), __we're just dealing with message passing__. Imagine how **fast** the unit test could be. ```ruby class Show < Hanami::Action - def initialize(configuration:, repository: ArticleRepository.new) - @repository = repository + def initialize(configuration:, repo: ArticleRepo.new) + @repo = repo super(configuration: configuration) end def handle(request, response) - response[:article] = repository.find(request.params[:id]) + response[:article] = repo.find(request.params[:id]) end private - attr_reader :repository + attr_reader :repo end configuration = Hanami::Controller::Configuration.new -action = Show.new(configuration: configuration, repository: ArticleRepository.new) +action = Show.new(configuration: configuration, repo: ArticleRepo.new) action.call(id: 23) ``` @@ -240,7 +240,7 @@ By default, an action exposes the received params. ```ruby class Show < Hanami::Action def handle(request, response) - response[:article] = ArticleRepository.new.find(request.params[:id]) + response[:article] = ArticleRepo.new.find(request.params[:id]) end end @@ -274,7 +274,7 @@ class Show < Hanami::Action # `request` and `response` in the method signature is optional def set_article(request, response) - response[:article] = ArticleRepository.new.find(request.params[:id]) + response[:article] = ArticleRepo.new.find(request.params[:id]) end end ``` @@ -284,7 +284,7 @@ Callbacks can also be expressed as anonymous lambdas: ```ruby class Show < Hanami::Action before { ... } # do some authentication stuff - before { |request, response| response[:article] = ArticleRepository.new.find(request.params[:id]) } + before { |request, response| response[:article] = ArticleRepo.new.find(request.params[:id]) } def handle(*) end @@ -434,7 +434,7 @@ Alternatively, you can specify a custom message. ```ruby class Show < Hanami::Action def handle(request, response) - response[:droid] = DroidRepository.new.find(request.params[:id]) or not_found + response[:droid] = DroidRepo.new.find(request.params[:id]) or not_found end private From 0277b08fed4db1f7b64325264b8f7b6f5ad35b93 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 22:41:14 -0600 Subject: [PATCH 03/26] Remove configuration from example --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7eba7d6f..5dd93f23 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Imagine how **fast** the unit test could be. ```ruby class Show < Hanami::Action - def initialize(configuration:, repo: ArticleRepo.new) + def initialize(repo: ArticleRepo.new, **) @repo = repo - super(configuration: configuration) + super(**) end def handle(request, response) @@ -88,8 +88,7 @@ class Show < Hanami::Action attr_reader :repo end -configuration = Hanami::Controller::Configuration.new -action = Show.new(configuration: configuration, repo: ArticleRepo.new) +action = Show.new(repo: ArticleRepo.new) action.call(id: 23) ``` From c681a20bdbeaf4504295c2b7b3895bcfa5b0ee9e Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:01:26 -0600 Subject: [PATCH 04/26] Fix Params section --- README.md | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5dd93f23..cc0ba65d 100644 --- a/README.md +++ b/README.md @@ -95,43 +95,59 @@ action.call(id: 23) ### Params The request params are part of the request passed as an argument to the `#handle` method. -If routed with *Hanami::Router*, it extracts the relevant bits from the Rack `env` (e.g. the requested `:id`). -Otherwise everything is passed as is: the full Rack `env` in production, and the given `Hash` for unit tests. -With `Hanami::Router`: +There are three scenarios for how params are extracted: + +**With Hanami::Router:** +When routed with *Hanami::Router*, it extracts and merges route parameters, query string parameters, and form parameters (with router params taking precedence). ```ruby class Show < Hanami::Action - def handle(request, *) + def handle(request, response) # ... - puts request.params # => { id: 23 } extracted from Rack env + puts request.params.to_h # => {id: 23, name: "john", age: "25"} end end + +# When called via router with route "/users/:id" and query string "?name=john&age=25" +Show.new.call({ + "router.params" => {id: 23}, + "QUERY_STRING" => "name=john&age=25" +}) ``` -Standalone: +**With Rack environment:** +When used in a Rack application (but without Hanami::Router), it extracts query string and form parameters from the request. ```ruby class Show < Hanami::Action - def handle(request, *) + def handle(request, response) # ... - puts request.params # => { :"rack.version"=>[1, 2], :"rack.input"=>#, ... } + puts request.params.to_h # => {name: "john", age: "25"} from query/form end end + +# When called with Rack env containing rack.input +Show.new.call({ + "rack.input" => StringIO.new("name=john&age=25"), + "CONTENT_TYPE" => "application/x-www-form-urlencoded" +}) ``` -Unit Testing: +**Standalone (testing):** +When called directly with a hash (typical in unit tests), it returns the given hash as-is. ```ruby class Show < Hanami::Action - def handle(request, *) + def handle(request, response) # ... - puts request.params # => { id: 23, key: "value" } passed as it is from testing + puts request.params.to_h # => {id: 23, name: "test"} end end -action = Show.new(configuration: configuration) -response = action.call(id: 23, key: "value") +# Direct call with hash for testing +action = Show.new +response = action.call(id: 23, name: "test") ``` #### Whitelisting From a45fa93399ee956b659ac3ebdcf00fb260272430 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:07:16 -0600 Subject: [PATCH 05/26] Fix/rename 'Declaring allowed param's section --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cc0ba65d..87b7261f 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,11 @@ action = Show.new response = action.call(id: 23, name: "test") ``` -#### Whitelisting +#### Declaring allowed params + +By default, params represent untrusted input. +For security reasons it's recommended to use hanami-validations to validate the input and remove invalid params. -Params represent an untrusted input. -For security reasons it's recommended to whitelist them. ```ruby require "hanami/validations" @@ -173,19 +174,17 @@ class Signup < Hanami::Action end def handle(request, *) - # Describe inheritance hierarchy - puts request.params.class # => Signup::Params - puts request.params.class.superclass # => Hanami::Action::Params - - # Whitelist :first_name, but not :admin + # :first_name is allowed, but not :admin is not puts request.params[:first_name] # => "Luca" puts request.params[:admin] # => nil - # Whitelist nested params [:address][:line_one], not [:address][:line_two] + # :address's :line_one is allowed, but :line_two is not puts request.params[:address][:line_one] # => "69 Tender St" puts request.params[:address][:line_two] # => nil end end + +Signup.new.call({first_name: "Luca", admin: true, address: { line_one: "69 Tender St"}}) ``` #### Validations & Coercions From 2f5f9de2e996ebbc145805c7ace36e8da89a05e2 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:15:58 -0600 Subject: [PATCH 06/26] Fix 'Validation and Coercions' section --- README.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 87b7261f..d8f5b5d7 100644 --- a/README.md +++ b/README.md @@ -175,23 +175,23 @@ class Signup < Hanami::Action def handle(request, *) # :first_name is allowed, but not :admin is not - puts request.params[:first_name] # => "Luca" + puts request.params[:first_name] # => "Jericho" puts request.params[:admin] # => nil # :address's :line_one is allowed, but :line_two is not - puts request.params[:address][:line_one] # => "69 Tender St" + puts request.params[:address][:line_one] # => "123 Motor City Blvd" puts request.params[:address][:line_two] # => nil end end -Signup.new.call({first_name: "Luca", admin: true, address: { line_one: "69 Tender St"}}) +Signup.new.call({first_name: "Jericho", admin: true, address: { line_one: "123 Motor City Blvd" }}) ``` #### Validations & Coercions -Because params are a well defined set of data required to fulfill a feature -in your application, you can validate them. So you can avoid hitting lower MVC layers -when params are invalid. +Because params are a well-defined set of data required to fulfill a request in your application, you can validate them. +In Hanami, we put validations at the action level, since different use-cases require different validation rules. +This also lets us ensure we have well-structured data further into our application. If you specify the `:type` option, the param will be coerced. @@ -205,8 +205,8 @@ class Signup < Hanami::Action params do required(:first_name).filled(:str?) required(:last_name).filled(:str?) - required(:email).filled?(:str?, format?: /\A.+@.+\z/) - required(:password).filled(:str?).confirmation + required(:email).filled(:str?, format?: /\A.+@.+\z/) + required(:password).filled(:str?) required(:terms_of_service).filled(:bool?) required(:age).filled(:int?, included_in?: 18..99) optional(:avatar).filled(size?: 1..(MEGABYTE * 3)) @@ -217,6 +217,16 @@ class Signup < Hanami::Action # ... end end + +Signup.new.call({}).status # => 400 +Signup.new.call({ + first_name: "Jericho", + last_name: "Jackson", + email: "actionjackson@example.com", + password: "password", + terms_of_service: true, + age: 40, + }).status # => 200 ``` ### Response From 7f3bb0885fc522f4798a348c82f475a532ed6538 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:26:56 -0600 Subject: [PATCH 07/26] Fix 'Response' section --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d8f5b5d7..e209c782 100644 --- a/README.md +++ b/README.md @@ -231,13 +231,13 @@ Signup.new.call({ ### Response -The output of `#call` is a `Hanami::Action::Response`: +The output of `#call` is a `Hanami::Action::Response` (which is a subclass of Rack::Response): ```ruby class Show < Hanami::Action end -action = Show.new(configuration: configuration) +action = Show.new action.call({}) # => # ``` @@ -245,7 +245,7 @@ This is the same `response` object passed to `#handle`, where you can use its ac ```ruby class Show < Hanami::Action - def handle(*, response) + def handle(request, response) response.status = 201 response.body = "Hi!" response.headers.merge!("X-Custom" => "OK") @@ -253,9 +253,12 @@ class Show < Hanami::Action end action = Show.new -action.call({}) # => [201, { "X-Custom" => "OK" }, ["Hi!"]] +action.call({}) # => [201, { "X-Custom" => "OK", ... }, ["Hi!"]] ``` +The Rack API requires response to be an Array with 3 elements: status, headers, and body. +You can call `#to_a` (or `#finish)` on the response to get that Rack representation. + ### Exposures In case you need to send data from the action to other layers of your application, you can use exposures. From 40af1eafeeebb5113d183fd996fa0696426f0103 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:30:51 -0600 Subject: [PATCH 08/26] Fix 'Exposures' section --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e209c782..86da701b 100644 --- a/README.md +++ b/README.md @@ -261,24 +261,25 @@ You can call `#to_a` (or `#finish)` on the response to get that Rack representat ### Exposures -In case you need to send data from the action to other layers of your application, you can use exposures. -By default, an action exposes the received params. +In case you need to send data from the action to other layers of your application, you can use exposures on the response. +By default, an action exposes the request's params and the format. ```ruby +Article = Data.define(:id) + class Show < Hanami::Action def handle(request, response) - response[:article] = ArticleRepo.new.find(request.params[:id]) + response[:article] = Article.new(id: request.params[:id]) end end -action = Show.new(configuration: configuration) +action = Show.new response = action.call(id: 23) -article = response[:article] -article.class # => Article -article.id # => 23 +puts response[:article].class # => Article +puts response[:article].id # => 23 -response.exposures.keys # => [:params, :article] +response.exposures.keys # => [:article, :params, :format] ``` ### Callbacks From 86468481481f914efe63bdac04bf9e4e8bed4615 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:40:07 -0600 Subject: [PATCH 09/26] Use full handle signature --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86da701b..729bcc5f 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ They are useful for shared logic like authentication checks. class Show < Hanami::Action before :authenticate, :set_article - def handle(*) + def handle(request, response) end private @@ -314,7 +314,7 @@ class Show < Hanami::Action before { ... } # do some authentication stuff before { |request, response| response[:article] = ArticleRepo.new.find(request.params[:id]) } - def handle(*) + def handle(request, response) end end ``` From aad3cc62d767e00a40aec276af36a1c14f4b5774 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Wed, 17 Sep 2025 23:46:09 -0600 Subject: [PATCH 10/26] Fix 'Exceptions management' section, remove inheritance section --- README.md | 83 ++++++++++++------------------------------------------- 1 file changed, 17 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 729bcc5f..c7d10396 100644 --- a/README.md +++ b/README.md @@ -330,28 +330,34 @@ An exception handler can be a valid HTTP status code (eg. `500`, `401`), or a `S class Show < Hanami::Action handle_exception StandardError => 500 - def handle(*) + def handle(request, response) raise end end -action = Show.new(configuration: configuration) -action.call({}) # => [500, {}, ["Internal Server Error"]] +action = Show.new +response = action.call({}) +p response.status # => 500 +p response.body # => ["Internal Server Error"] ``` You can map a specific raised exception to a different HTTP status. ```ruby +RecordNotFound = Class.new(StandardError) + class Show < Hanami::Action handle_exception RecordNotFound => 404 - def handle(*) + def handle(request, response) raise RecordNotFound end end -action = Show.new(configuration: configuration) -action.call({}) # => [404, {}, ["Not Found"]] +action = Show.new +response = action.call({}) +p response.status # => 404 +p response.body # ["Not Found"] ``` You can also define custom handlers for exceptions. @@ -360,7 +366,7 @@ You can also define custom handlers for exceptions. class Create < Hanami::Action handle_exception ArgumentError => :my_custom_handler - def handle(*) + def handle(request, response) raise ArgumentError.new("Invalid arguments") end @@ -372,67 +378,12 @@ class Create < Hanami::Action end end -action = Create.new(configuration: configuration) -action.call({}) # => [400, {}, ["Invalid arguments"]] +action = Create.new +response = action.call({}) +p response.status # => 400 +p response.body # => ["Invalid arguments"] ``` -Exception policies can be defined globally via configuration: - -```ruby -configuration = Hanami::Controller::Configuration.new do |config| - config.handle_exception RecordNotFound => 404 -end - -class Show < Hanami::Action - def handle(*) - raise RecordNotFound - end -end - -action = Show.new(configuration: configuration) -action.call({}) # => [404, {}, ["Not Found"]] -``` - -#### Inherited Exceptions - -```ruby -class MyCustomException < StandardError -end - -module Articles - class Index < Hanami::Action - handle_exception MyCustomException => :handle_my_exception - - def handle(*) - raise MyCustomException - end - - private - - def handle_my_exception(request, response, exception) - # ... - end - end - - class Show < Hanami::Action - handle_exception StandardError => :handle_standard_error - - def handle(*) - raise MyCustomException - end - - private - - def handle_standard_error(request, response, exception) - # ... - end - end -end - -Articles::Index.new.call({}) # => `handle_my_exception` will be invoked -Articles::Show.new.call({}) # => `handle_standard_error` will be invoked, - # because `MyCustomException` inherits from `StandardError` -``` ### Throwable HTTP statuses From c78922ad9e01c5aac3a0cd8a2887325913f104e5 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 11:47:50 -0600 Subject: [PATCH 11/26] Improve 'Throwable HTTP statuses'/halt section --- README.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c7d10396..a3da0b44 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ When `#halt` is used with a valid HTTP code, it stops the execution and sets the class Show < Hanami::Action before :authenticate! - def handle(*) + def handle(request, response) # ... end @@ -402,15 +402,23 @@ class Show < Hanami::Action def authenticate! halt 401 unless authenticated? end + + def authenticated? + false # to demonstrate the use of `#halt` + end end -action = Show.new(configuration: configuration) -action.call({}) # => [401, {}, ["Unauthorized"]] +action = Show.new +response = action.call({}) +p response.status #=> 401 +p response.body # => ["Unauthorized"] ``` -Alternatively, you can specify a custom message. +Alternatively, you can specify a custom message to be used in the response body: ```ruby +class DroidRepo; def find(id) = nil; end; + class Show < Hanami::Action def handle(request, response) response[:droid] = DroidRepo.new.find(request.params[:id]) or not_found @@ -423,8 +431,10 @@ class Show < Hanami::Action end end -action = Show.new(configuration: configuration) -action.call({}) # => [404, {}, ["This is not the droid you're looking for"]] +action = Show.new +response = action.call({}) +p response.status # => 404 +p response.body # => ["This is not the droid you're looking for"] ``` ### Cookies From 3233f6b0be3c8306c47bc96a0d2a9552e703e43b Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 12:38:15 -0600 Subject: [PATCH 12/26] Fix 'Sessions' section --- README.md | 74 +++++++++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a3da0b44..de445ed6 100644 --- a/README.md +++ b/README.md @@ -442,106 +442,102 @@ p response.body # => ["This is not the droid you're looking for"] You can read the original cookies sent from the HTTP client via `request.cookies`. If you want to send cookies in the response, use `response.cookies`. -They are read as a Hash from Rack env: +They are read as a Hash on the request (using String keys), coming from the Rack env: ```ruby require "hanami/controller" -require "hanami/action/cookies" class ReadCookiesFromRackEnv < Hanami::Action - include Hanami::Action::Cookies - def handle(request, *) + def handle(request, response) # ... - request.cookies[:foo] # => "bar" + puts request.cookies["foo"] # => "bar" end end -action = ReadCookiesFromRackEnv.new(configuration: configuration) +action = ReadCookiesFromRackEnv.new action.call({"HTTP_COOKIE" => "foo=bar"}) ``` -They are set like a Hash: +They are set like a Hash, once `include Hanami::Action::Cookies` is used: ```ruby require "hanami/controller" -require "hanami/action/cookies" class SetCookies < Hanami::Action include Hanami::Action::Cookies - def handle(*, response) + def handle(request, response) # ... - response.cookies[:foo] = "bar" + response.cookies["foo"] = "bar" end end -action = SetCookies.new(configuration: configuration) -action.call({}) # => [200, {"Set-Cookie" => "foo=bar"}, "..."] +action = SetCookies.new +action.call({}).headers.fetch("Set-Cookie") # "foo=bar" ``` They are removed by setting their value to `nil`: ```ruby require "hanami/controller" -require "hanami/action/cookies" class RemoveCookies < Hanami::Action include Hanami::Action::Cookies - def handle(*, response) + def handle(request, response) # ... response.cookies[:foo] = nil end end -action = RemoveCookies.new(configuration: configuration) -action.call({}) # => [200, {"Set-Cookie" => "foo=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"}, "..."] +action = RemoveCookies.new +action.call({}).headers.fetch("Set-Cookie") # => "foo=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000" ``` Default values can be set in configuration, but overridden case by case. ```ruby require "hanami/controller" -require "hanami/action/cookies" - -configuration = Hanami::Controller::Configuration.new do |config| - config.cookies(max_age: 300) # 5 minutes -end class SetCookies < Hanami::Action include Hanami::Action::Cookies - def handle(*, response) + config.cookies = { max_age: 300 } + + def handle(request, response) # ... - response.cookies[:foo] = { value: "bar", max_age: 100 } + response.cookies[:foo] = { value: "bar" } + response.cookies[:baz] = { value: "boo", max_age: 100 } end end -action = SetCookies.new(configuration: configuration) -action.call({}) # => [200, {"Set-Cookie" => "foo=bar; max-age=100;"}, "..."] +action = SetCookies.new +p action.call({}).headers.fetch("Set-Cookie").lines +# => ["foo=bar; max-age=300\n", +# "baz=boo; max-age=100; expires=Thu, 18 Sep 2025 18:14:18 GMT"] ``` ### Sessions -Actions have builtin support for Rack sessions. -Similarly to cookies, you can read the session sent by the HTTP client via -`request.session`, and also manipulate it via `response.session`. +Actions have built-in support for Rack sessions. +Similarly to cookies, you can read the session sent by the HTTP client via `request.session`, +and manipulate it via `response.session`. ```ruby require "hanami/controller" -require "hanami/action/session" class ReadSessionFromRackEnv < Hanami::Action include Hanami::Action::Session + config.session = { expire_after: 3600 } def handle(request, *) # ... - request.session[:age] # => "35" + puts request.session[:age] # => "35" end end -action = ReadSessionFromRackEnv.new(configuration: configuration) +action = ReadSessionFromRackEnv.new action.call({ "rack.session" => { "age" => "35" } }) ``` @@ -554,14 +550,16 @@ require "hanami/action/session" class SetSession < Hanami::Action include Hanami::Action::Session - def handle(*, response) + def handle(request, response) # ... response.session[:age] = 31 end end -action = SetSession.new(configuration: configuration) -action.call({}) # => [200, {"Set-Cookie"=>"rack.session=..."}, "..."] +action = SetSession.new +response = action.call({}) +response.session # => { age: 31 } +# Also available via response.env["rack.session"] ``` Values can be removed like a Hash: @@ -573,14 +571,14 @@ require "hanami/action/session" class RemoveSession < Hanami::Action include Hanami::Action::Session - def handle(*, response) + def handle(request, response) # ... response.session[:age] = nil end end -action = RemoveSession.new(configuration: configuration) -action.call({}) # => [200, {"Set-Cookie"=>"rack.session=..."}, "..."] it removes that value from the session +action = RemoveSession.new +action.call({}).session # => {age: nil} ``` While Hanami::Controller supports sessions natively, it's **session store agnostic**. @@ -588,7 +586,7 @@ You have to specify the session store in your Rack middleware configuration (eg ```ruby use Rack::Session::Cookie, secret: SecureRandom.hex(64) -run Show.new(configuration: configuration) +run Show.new ``` ### HTTP Cache From 08a2fcda57037a4c2c1212f9033c973a1004d75f Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 15:07:46 -0600 Subject: [PATCH 13/26] Link out to RFC in text, instead of separately --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de445ed6..a37e79c2 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ run Show.new ### HTTP Cache -Hanami::Controller sets your headers correctly according to RFC 2616 / 14.9 for more on standard cache control directives: http://tools.ietf.org/html/rfc2616#section-14.9.1 +Hanami::Controller sets your headers correctly according to [RFC 2616, sec. 14.9](http://tools.ietf.org/html/rfc2616#section-14.9.1). You can easily set the Cache-Control header for your actions: From 283f918d8663b9b81e9773b47e952022478d27e4 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 15:23:09 -0600 Subject: [PATCH 14/26] Fix 'HTTP Cache' section --- README.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a37e79c2..66d9be95 100644 --- a/README.md +++ b/README.md @@ -597,32 +597,39 @@ You can easily set the Cache-Control header for your actions: ```ruby require "hanami/controller" -require "hanami/action/cache" -class HttpCacheController < Hanami::Action +class HttpCache < Hanami::Action include Hanami::Action::Cache - cache_control :public, max_age: 600 # => Cache-Control: public, max-age=600 - def handle(*) + cache_control :public, max_age: 600 + + def handle(request, response) # ... end end + +response = HttpCache.new.call({}) +puts response.headers.fetch("Cache-Control") # => "public, max-age=600" ``` Expires header can be specified using `expires` method: ```ruby require "hanami/controller" -require "hanami/action/cache" -class HttpCacheController < Hanami::Action +class HttpCache < Hanami::Action include Hanami::Action::Cache - expires 60, :public, max_age: 600 # => Expires: Sun, 03 Aug 2014 17:47:02 GMT, Cache-Control: public, max-age=600 - def handle(*) + expires 600, :public + + def handle(request, response) # ... end end + +response = HttpCache.new.call({}) +p response.headers.fetch("Expires") # => "Thu, 18 Sep 2025 21:30:00 GMT" (600 seconds from `Time.now`) +p response.headers.fetch("Cache-Control") # => "public, max-age=600" ``` ### Conditional Get From f6fc966a20ff13103961215811289ed7f6713ad5 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 16:02:01 -0600 Subject: [PATCH 15/26] Fix 'Conditional Get' section --- README.md | 54 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 66d9be95..99f32f20 100644 --- a/README.md +++ b/README.md @@ -634,47 +634,71 @@ p response.headers.fetch("Cache-Control") # => "public, max-age=600" ### Conditional Get -According to HTTP specification, conditional GETs provide a way for web servers to inform clients that the response to a GET request hasn't change since the last request returning a `304 (Not Modified)` response. +According to the HTTP specification, +a conditional GET allows a client to ask a server to send a representation of the data only if it has changed. +If the resource hasn’t changed, +the server responds with *304 Not Modified*; +otherwise, it returns the full representation with *200 OK*. -Passing the `HTTP_IF_NONE_MATCH` (content identifier) or `HTTP_IF_MODIFIED_SINCE` (timestamp) headers allows the web server define if the client has a fresh version of a given resource. +Passing the 'If-None-Match' header (with ETag content identifier) +or 'If-Modified-Since' header (with a timestamp) headers +allows the server to determine whether the client already has a fresh copy of the resource. +Note that they way to use them in Rack is via the `"HTTP_IF_NONE_MATCH"` and `"HTTP_IF_MODIFIED_SINCE"` env keys on the request. -You can easily take advantage of Conditional Get using `#fresh` method: +You can easily take advantage of Conditional Get using `#fresh` method. ```ruby require "hanami/controller" require "hanami/action/cache" -class ConditionalGetController < Hanami::Action +Resource = Data.define(:cache_key) + +class ConditionalGetEtag < Hanami::Action include Hanami::Action::Cache - def handle(*) + def handle(request, response) # ... - fresh etag: resource.cache_key - # => halt 304 with header IfNoneMatch = resource.cache_key + resource = Resource.new(cache_key: "abc123") + response.fresh(etag: resource.cache_key) + # => `halt 304` when value of header 'If-None-Match' is same as the `etag:` value end end -``` -If `resource.cache_key` is equal to `IfNoneMatch` header, then hanami will `halt 304`. +first_response = ConditionalGetEtag.new.call({}) +p first_response.status # => 200 + +second_response = ConditionalGetEtag.new.call({"HTTP_IF_NONE_MATCH" => "abc123"}) +p second_response.status # => 304 +``` -An alternative to hashing based check, is the time based check: +An alternative to hash-based freshness check, is a time-based check with 'If-Modified-Since'. +If the resource hasn’t been modified since the time specified in the `If-Modified-Since` header, +the server responds with *304 Not Modified*. ```ruby require "hanami/controller" require "hanami/action/cache" -class ConditionalGetController < Hanami::Action +Resource = Data.define(:updated_at) + +class ConditionalGetTime < Hanami::Action include Hanami::Action::Cache - def handle(*) + def handle(request, response) # ... - fresh last_modified: resource.updated_at - # => halt 304 with header IfModifiedSince = resource.updated_at.httpdate + resource = Resource.new(updated_at: Time.now - 60) # i.e. last updated 1 minute ago + response.fresh(last_modified: resource.updated_at) + # => `halt 304` when value of header 'If-Modified-Since' is after the `last_modified:` value end end + +first_response = ConditionalGetTime.new.call({}) +p first_response.status # => 200 + +second_response = ConditionalGetTime.new.call({"HTTP_IF_MODIFIED_SINCE" => Time.now.httpdate}) +p second_response.status # => 304 ``` -If `resource.updated_at` is equal to `IfModifiedSince` header, then hanami will `halt 304`. ### Redirect From 28461980b1e05635d4c7bc4cffb1aa8666979096 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 16:07:19 -0600 Subject: [PATCH 16/26] Fix 'Redirect' section --- README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 99f32f20..114ab5d4 100644 --- a/README.md +++ b/README.md @@ -705,29 +705,35 @@ p second_response.status # => 304 If you need to redirect the client to another resource, use `response.redirect_to`: ```ruby -class Create < Hanami::Action - def handle(*, response) +require "hanami/controller" + +class RedirectAction < Hanami::Action + def handle(request, response) # ... response.redirect_to "http://example.com/articles/23" end end -action = Create.new(configuration: configuration) -action.call({ article: { title: "Hello" }}) # => [302, {"Location" => "/articles/23"}, ""] +response = RedirectAction.new.call({}) +p response.status # => 302 +p response.location # => "http://example.com/articles/23" (same as `response.headers["Location"]`) ``` You can also redirect with a custom status code: ```ruby -class Create < Hanami::Action - def handle(*, response) +require "hanami/controller" + +class PermanentRedirectAction < Hanami::Action + def handle(request, response) # ... - response.redirect_to "http://example.com/articles/23", status: 301 + response.redirect_to "/articles/23", status: 301 end end -action = Create.new(configuration: configuration) -action.call({ article: { title: "Hello" }}) # => [301, {"Location" => "/articles/23"}, ""] +response = PermanentRedirectAction.new.call({}) +p response.status # => 301 +p response.location # => "/articles/23" ``` ### MIME Types From ecb3e8c83444f8d1448871e23a2f8a05f4e2d115 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 16:44:24 -0600 Subject: [PATCH 17/26] Fix 'MIME Types' section --- README.md | 97 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 114ab5d4..6b520bcf 100644 --- a/README.md +++ b/README.md @@ -741,81 +741,108 @@ p response.location # => "/articles/23" `Hanami::Action` automatically sets the `Content-Type` header, according to the request. ```ruby -class Show < Hanami::Action - def handle(*) +require "hanami/controller" + +class ResponseFormatAction < Hanami::Action + def handle(request, response) end end -action = Show.new(configuration: configuration) +action = ResponseFormatAction.new -response = action.call({ "HTTP_ACCEPT" => "*/*" }) # Content-Type "application/octet-stream" -response.format # :all +first_response = action.call({ "HTTP_ACCEPT" => "*/*" }) +p first_response.format # :all +p first_response.content_type # "application/octet-stream; charset=utf-8" -response = action.call({ "HTTP_ACCEPT" => "text/html" }) # Content-Type "text/html" -response.format # :html +second_response = action.call({ "HTTP_ACCEPT" => "text/html" }) +p second_response.format # :html +p second_response.content_type # "text/html; charset=utf-8" ``` However, you can force this value: ```ruby -class Show < Hanami::Action - def handle(*, response) +require "hanami/controller" + +class ForcedFormatAction < Hanami::Action + def handle(request, response) # ... response.format = :json end end -action = Show.new(configuration: configuration) +action = ForcedFormatAction.new -response = action.call({ "HTTP_ACCEPT" => "*/*" }) # Content-Type "application/json" -response.format # :json +first_response = action.call({ "HTTP_ACCEPT" => "*/*" }) +p first_response.format # :json +p first_response.content_type # "application/json; charset=utf-8" -response = action.call({ "HTTP_ACCEPT" => "text/html" }) # Content-Type "application/json" -response.format # :json +second_response = action.call({ "HTTP_ACCEPT" => "text/html" }) +p second_response.format # :json +p second_response.content_type # "application/json; charset=utf-8" ``` You can restrict the accepted MIME types: ```ruby -class Show < Hanami::Action - accept :html, :json +require "hanami/controller" - def handle(*) +class RestrictedTypesActionShow < Hanami::Action + format :html, :json + + def handle(request, response) # ... end end -# When called with "*/*" => 200 -# When called with "text/html" => 200 -# When called with "application/json" => 200 -# When called with "application/xml" => 415 + +action = RestrictedTypesActionShow.new + +any_format_response = action.call({ "HTTP_ACCEPT" => "*/*" }) +p any_format_response.status # => 200 +p any_format_response.format # :html (since it was listed first) + +html_response = action.call({ "HTTP_ACCEPT" => "text/html" }) +p html_response.status # => 200 +p html_response.format # :html + +json_response = action.call({ "HTTP_ACCEPT" => "application/json" }) +p json_response.status # => 200 +p json_response.format # :json + +xml_response = action.call({ "HTTP_ACCEPT" => "application/xml" }) +p xml_response.status # => 406 (Not Acceptable) + ``` You can check if the requested MIME type is accepted by the client. ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class CheckAcceptsAction < Hanami::Action def handle(request, response) # ... # request.env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9" - request.accept?("text/html") # => true - request.accept?("application/xml") # => true - request.accept?("application/json") # => false - response.format # :html - - - # request.env["HTTP_ACCEPT"] # => "*/*" - - request.accept?("text/html") # => true - request.accept?("application/xml") # => true - request.accept?("application/json") # => true - response.format # :html + puts "Accepts header: #{request.env["HTTP_ACCEPT"]}" + puts "Accepts text/html? #{request.accept?("text/html")}" + puts "Accepts application/xml? #{request.accept?("application/xml")}" + puts "Accepts application/json? #{request.accept?("application/json")}" + puts "Response format: #{response.format.inspect}" + puts end end + +action = CheckAcceptsAction.new + +action.call({ "HTTP_ACCEPT" => "text/html" }) +action.call({ "HTTP_ACCEPT" => "text/html,application/xhtml+xml,application/xml;q=0.9" }) +action.call({ "HTTP_ACCEPT" => "application/json" }) +action.call({ "HTTP_ACCEPT" => "*/*" }) ``` -Hanami::Controller is shipped with an extensive list of the most common MIME types. +Hanami::Controller shipped with an extensive list of the most common MIME types. Also, you can register your own: ```ruby From a8aba29f2bbddd660d8659f84dd0bcb4de86e8e4 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 16:54:53 -0600 Subject: [PATCH 18/26] Fix (and add header to) 'Custom foramts' section --- README.md | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6b520bcf..ce1d0076 100644 --- a/README.md +++ b/README.md @@ -842,35 +842,48 @@ action.call({ "HTTP_ACCEPT" => "application/json" }) action.call({ "HTTP_ACCEPT" => "*/*" }) ``` -Hanami::Controller shipped with an extensive list of the most common MIME types. -Also, you can register your own: +#### Custom Formats + +Hanami::Controller ships with an extensive list of the most common MIME types. +You can also register your own: ```ruby -configuration = Hanami::Controller::Configuration.new do |config| - config.format custom: "application/custom" -end +require "hanami/controller" + +class CustomFormatAcceptAction < Hanami::Action + config.formats.add :custom, "application/custom" -class Index < Hanami::Action def handle(*) end end -action = Index.new(configuration: configuration) +action = CustomFormatAcceptAction.new -response = action.call({ "HTTP_ACCEPT" => "application/custom" }) # => Content-Type "application/custom" -response.format # => :custom +response = action.call({ "HTTP_ACCEPT" => "application/custom" }) +p response.format # => :custom +p response.content_type # => "application/custom; charset=utf-8" +``` -class Show < Hanami::Action - def handle(*, response) + +You can also manually set the format on the response: + +```ruby +require "hanami/controller" + +class ManualFormatAction < Hanami::Action + config.formats.add :custom, "application/custom" + + def handle(request, response) # ... response.format = :custom end end -action = Show.new(configuration: configuration) +action = ManualFormatAction.new -response = action.call({ "HTTP_ACCEPT" => "*/*" }) # => Content-Type "application/custom" -response.format # => :custom +response = action.call({ "HTTP_ACCEPT" => "*/*" }) +p response.format # => :custom +p response.content_type # => "application/custom; charset=utf-8" ``` ### Streamed Responses From cfde6b0f3912a6086350b9d16af2d1360e20e7b3 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Thu, 18 Sep 2025 19:07:42 -0600 Subject: [PATCH 19/26] Remove streaming examples --- README.md | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ce1d0076..40276488 100644 --- a/README.md +++ b/README.md @@ -886,38 +886,10 @@ p response.format # => :custom p response.content_type # => "application/custom; charset=utf-8" ``` -### Streamed Responses - -When the work to be done by the server takes time, it may be a good idea to stream your response. Here's an example of a streamed CSV. - -```ruby -configuration = Hanami::Controller::Configuration.new do |config| - config.format csv: 'text/csv' -end - -class Csv < Hanami::Action - def handle(*, response) - response.format = :csv - response.body = Enumerator.new do |yielder| - yielder << csv_header - - # Expensive operation is streamed as each line becomes available - csv_body.each_line do |line| - yielder << line - end - end - end -end -``` - -Note: -* In development, Hanami' code reloading needs to be disabled for streaming to work. This is because `Shotgun` interferes with the streaming action. You can disable it like this `hanami server --code-reloading=false` -* Streaming does not work with WEBrick as it buffers its response. We recommend using `puma`, though you may find success with other servers - ### No rendering, please -Hanami::Controller is designed to be a pure HTTP endpoint, rendering belongs to other layers of MVC. -You can set the body directly (see [response](#response)), or use [Hanami::View](https://github.com/hanami/view). +Hanami::Controller is designed to be a pure HTTP endpoint, rendering belongs to the View layer. +You can set the body directly (see [response](#response)), use [Hanami::View](https://github.com/hanami/view). ### Controllers From 68bd76ad6e079e9e4a9f28768d8d66b79ad3a1f1 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Fri, 19 Sep 2025 15:47:13 -0600 Subject: [PATCH 20/26] Clean up - Rename example classes to unique names - Switch to 'p' instead of mixing 'puts' - Add missing require hanami/controllers --- README.md | 164 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 102 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 40276488..97931bc4 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,16 @@ The core of this framework are the actions. They are the endpoints that respond to incoming HTTP requests. ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class HelloWorld < Hanami::Action def handle(request, response) - response[:article] = ArticleRepo.new.find(request.params[:id]) + response.body = "Hello World!" end end + +response = HelloWorld.new.call({}) +p response.body # => ["Hello World!"] ``` `Hanami::Action` follows the Hanami philosophy: a single purpose object with a minimal interface. @@ -73,7 +78,7 @@ __We're avoiding HTTP calls__, we're also going to avoid hitting the database (i Imagine how **fast** the unit test could be. ```ruby -class Show < Hanami::Action +class ShowArticle < Hanami::Action def initialize(repo: ArticleRepo.new, **) @repo = repo super(**) @@ -88,7 +93,7 @@ class Show < Hanami::Action attr_reader :repo end -action = Show.new(repo: ArticleRepo.new) +action = ShowArticle.new(repo: ArticleRepo.new) action.call(id: 23) ``` @@ -102,15 +107,17 @@ There are three scenarios for how params are extracted: When routed with *Hanami::Router*, it extracts and merges route parameters, query string parameters, and form parameters (with router params taking precedence). ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class InspectParams < Hanami::Action def handle(request, response) # ... - puts request.params.to_h # => {id: 23, name: "john", age: "25"} + p request.params.to_h # => {id: 23, name: "john", age: "25"} end end # When called via router with route "/users/:id" and query string "?name=john&age=25" -Show.new.call({ +InspectParams.new.call({ "router.params" => {id: 23}, "QUERY_STRING" => "name=john&age=25" }) @@ -120,15 +127,17 @@ Show.new.call({ When used in a Rack application (but without Hanami::Router), it extracts query string and form parameters from the request. ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class ParamsFromRackInput < Hanami::Action def handle(request, response) # ... - puts request.params.to_h # => {name: "john", age: "25"} from query/form + p request.params.to_h # => {name: "john", age: "25"} from query/form end end # When called with Rack env containing rack.input -Show.new.call({ +ParamsFromRackInput.new.call({ "rack.input" => StringIO.new("name=john&age=25"), "CONTENT_TYPE" => "application/x-www-form-urlencoded" }) @@ -138,15 +147,17 @@ Show.new.call({ When called directly with a hash (typical in unit tests), it returns the given hash as-is. ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class ParamsFromHash < Hanami::Action def handle(request, response) # ... - puts request.params.to_h # => {id: 23, name: "test"} + p request.params.to_h # => {id: 23, name: "test"} end end # Direct call with hash for testing -action = Show.new +action = ParamsFromHash.new response = action.call(id: 23, name: "test") ``` @@ -175,12 +186,12 @@ class Signup < Hanami::Action def handle(request, *) # :first_name is allowed, but not :admin is not - puts request.params[:first_name] # => "Jericho" - puts request.params[:admin] # => nil + p request.params[:first_name] # => "Jericho" + p request.params[:admin] # => nil # :address's :line_one is allowed, but :line_two is not - puts request.params[:address][:line_one] # => "123 Motor City Blvd" - puts request.params[:address][:line_two] # => nil + p request.params[:address][:line_one] # => "123 Motor City Blvd" + p request.params[:address][:line_two] # => nil end end @@ -199,7 +210,7 @@ If you specify the `:type` option, the param will be coerced. require "hanami/validations" require "hanami/controller" -class Signup < Hanami::Action +class SignupValidateParams < Hanami::Action MEGABYTE = 1024 ** 2 params do @@ -218,33 +229,35 @@ class Signup < Hanami::Action end end -Signup.new.call({}).status # => 400 -Signup.new.call({ - first_name: "Jericho", - last_name: "Jackson", - email: "actionjackson@example.com", - password: "password", - terms_of_service: true, - age: 40, - }).status # => 200 +SignupValidateParams.new.call({}).status # => 400 +SignupValidateParams.new.call({ + first_name: "Jericho", + last_name: "Jackson", + email: "actionjackson@example.com", + password: "password", + terms_of_service: true, + age: 40, +}).status # => 200 ``` ### Response -The output of `#call` is a `Hanami::Action::Response` (which is a subclass of Rack::Response): +The output of `#call` is a `Hanami::Action::Response` (which is a subclass of [Rack::Response](https://github.com/rack/rack/blob/main/lib/rack/response.rb)): ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class ReturnsResponse < Hanami::Action end -action = Show.new -action.call({}) # => # +action = ReturnsResponse.new +action.call({}).class # => Hanami::Action::Response ``` This is the same `response` object passed to `#handle`, where you can use its accessors to explicitly set status, headers, and body: ```ruby -class Show < Hanami::Action +class ManiplateResponse < Hanami::Action def handle(request, response) response.status = 201 response.body = "Hi!" @@ -252,7 +265,7 @@ class Show < Hanami::Action end end -action = Show.new +action = ManipulateResponse.new action.call({}) # => [201, { "X-Custom" => "OK", ... }, ["Hi!"]] ``` @@ -267,19 +280,18 @@ By default, an action exposes the request's params and the format. ```ruby Article = Data.define(:id) -class Show < Hanami::Action +class ExposeArticle < Hanami::Action def handle(request, response) response[:article] = Article.new(id: request.params[:id]) end end -action = Show.new -response = action.call(id: 23) +response = ExposeAticle.new.call(id: 23) -puts response[:article].class # => Article -puts response[:article].id # => 23 +p response[:article].class # => Article +p response[:article].id # => 23 -response.exposures.keys # => [:article, :params, :format] +p response.exposures.keys # => [:article, :params, :format] ``` ### Callbacks @@ -288,7 +300,13 @@ If you need to execute logic **before** or **after** `#handle` is invoked, you c They are useful for shared logic like authentication checks. ```ruby -class Show < Hanami::Action +require "hanami/controller" + +Article = Data.define(:title) +ArticleRepo = Class.new { def find(id) = Article.new(title: "Why Hanami? Reason ##{id}") } + +Data.define(:title) +class BeforeCallbackMethodName < Hanami::Action before :authenticate, :set_article def handle(request, response) @@ -305,18 +323,31 @@ class Show < Hanami::Action response[:article] = ArticleRepo.new.find(request.params[:id]) end end + +response = BeforeCallbackMethodName.new.call({id: 1000}) + +p response[:article].title # => "Why Hanami? Reason #1000" ``` Callbacks can also be expressed as anonymous lambdas: ```ruby -class Show < Hanami::Action - before { ... } # do some authentication stuff +require "hanami/controller" + +Article = Data.define(:title) +ArticleRepo = Class.new { def find(id) = Article.new(title: "Why Hanami? Reason ##{id}") } + +class BeforeCallbackLambda < Hanami::Action + before { } # do some authentication stuff before { |request, response| response[:article] = ArticleRepo.new.find(request.params[:id]) } def handle(request, response) + p "Article: #{response[:article].title}" end end + +response = BeforeCallbackLambda.new.call({id: 1001}) +p response[:article].title # => "Why Hanami? Reason #1001" ``` ### Exceptions management @@ -327,7 +358,9 @@ You can write custom exception handling on per action or configuration basis. An exception handler can be a valid HTTP status code (eg. `500`, `401`), or a `Symbol` that represents an action method. ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class HandleStandardError < Hanami::Action handle_exception StandardError => 500 def handle(request, response) @@ -335,7 +368,7 @@ class Show < Hanami::Action end end -action = Show.new +action = HandleStandardError.new response = action.call({}) p response.status # => 500 p response.body # => ["Internal Server Error"] @@ -344,9 +377,11 @@ p response.body # => ["Internal Server Error"] You can map a specific raised exception to a different HTTP status. ```ruby +require "hanami/controller" + RecordNotFound = Class.new(StandardError) -class Show < Hanami::Action +class HandleCustomException < Hanami::Action handle_exception RecordNotFound => 404 def handle(request, response) @@ -354,7 +389,7 @@ class Show < Hanami::Action end end -action = Show.new +action = HandleCustomException.new response = action.call({}) p response.status # => 404 p response.body # ["Not Found"] @@ -363,7 +398,9 @@ p response.body # ["Not Found"] You can also define custom handlers for exceptions. ```ruby -class Create < Hanami::Action +require "hanami/controller" + +class CustomHandler < Hanami::Action handle_exception ArgumentError => :my_custom_handler def handle(request, response) @@ -378,7 +415,7 @@ class Create < Hanami::Action end end -action = Create.new +action = CustomHandler.new response = action.call({}) p response.status # => 400 p response.body # => ["Invalid arguments"] @@ -390,7 +427,9 @@ p response.body # => ["Invalid arguments"] When `#halt` is used with a valid HTTP code, it stops the execution and sets the proper status and body for the response: ```ruby -class Show < Hanami::Action +require "hanami/controller" + +class ThrowUnauthorized < Hanami::Action before :authenticate! def handle(request, response) @@ -408,7 +447,7 @@ class Show < Hanami::Action end end -action = Show.new +action = ThrowUnauthorized.new response = action.call({}) p response.status #=> 401 p response.body # => ["Unauthorized"] @@ -417,9 +456,11 @@ p response.body # => ["Unauthorized"] Alternatively, you can specify a custom message to be used in the response body: ```ruby +require "hanami/controller" + class DroidRepo; def find(id) = nil; end; -class Show < Hanami::Action +class FindDroid < Hanami::Action def handle(request, response) response[:droid] = DroidRepo.new.find(request.params[:id]) or not_found end @@ -431,8 +472,7 @@ class Show < Hanami::Action end end -action = Show.new -response = action.call({}) +response = FindDroid.new.call({}) p response.status # => 404 p response.body # => ["This is not the droid you're looking for"] ``` @@ -451,7 +491,7 @@ class ReadCookiesFromRackEnv < Hanami::Action def handle(request, response) # ... - puts request.cookies["foo"] # => "bar" + p request.cookies["foo"] # => "bar" end end @@ -533,7 +573,7 @@ class ReadSessionFromRackEnv < Hanami::Action def handle(request, *) # ... - puts request.session[:age] # => "35" + p request.session[:age] # => "35" end end @@ -609,7 +649,7 @@ class HttpCache < Hanami::Action end response = HttpCache.new.call({}) -puts response.headers.fetch("Cache-Control") # => "public, max-age=600" +p response.headers.fetch("Cache-Control") # => "public, max-age=600" ``` Expires header can be specified using `expires` method: @@ -825,12 +865,12 @@ class CheckAcceptsAction < Hanami::Action # ... # request.env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9" - puts "Accepts header: #{request.env["HTTP_ACCEPT"]}" - puts "Accepts text/html? #{request.accept?("text/html")}" - puts "Accepts application/xml? #{request.accept?("application/xml")}" - puts "Accepts application/json? #{request.accept?("application/json")}" - puts "Response format: #{response.format.inspect}" - puts + p "Accepts header: #{request.env["HTTP_ACCEPT"]}" + p "Accepts text/html? #{request.accept?("text/html")}" + p "Accepts application/xml? #{request.accept?("application/xml")}" + p "Accepts application/json? #{request.accept?("application/json")}" + p "Response format: #{response.format.inspect}" + p end end From 87c1cc0f9ccd30f5f924a279592ba7d12d1034e7 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Fri, 19 Sep 2025 16:14:04 -0600 Subject: [PATCH 21/26] Remove 'Hanami::Router integration' section --- README.md | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/README.md b/README.md index 97931bc4..7d23d85a 100644 --- a/README.md +++ b/README.md @@ -946,30 +946,7 @@ module Articles end end -Articles::Index.new(configuration: configuration).call({}) -``` - -### Hanami::Router integration - -```ruby -require "hanami/router" -require "hanami/controller" - -module Web - module Controllers - module Books - class Show < Hanami::Action - def handle(*) - end - end - end - end -end - -configuration = Hanami::Controller::Configuration.new -router = Hanami::Router.new(configuration: configuration, namespace: Web::Controllers) do - get "/books/:id", "books#show" -end +Articles::Index.new().call({}) ``` ### Rack integration From 8c73f1fdc0c4744fde783cffccce1aea676f0172 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Fri, 19 Sep 2025 16:21:28 -0600 Subject: [PATCH 22/26] Update 'Configuration' section --- README.md | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7d23d85a..9664e6b6 100644 --- a/README.md +++ b/README.md @@ -955,36 +955,61 @@ Hanami::Controller is compatible with Rack. If you need to use any Rack middlewa ### Configuration -Hanami::Controller can be configured via `Hanami::Controller::Configuration`. -It supports a few options: +Hanami::Action can be configured via `config` on the action class. +It supports the following options: ```ruby require "hanami/controller" -configuration = Hanami::Controller::Configuration.new do |config| +class MyAction < Hanami::Action # If the given exception is raised, return that HTTP status # It can be used multiple times # Argument: hash, empty by default # - config.handle_exception ArgumentError => 404 + config.handle_exception ArgumentError => 400 - # Register a format to MIME type mapping - # Argument: hash, key: format symbol, value: MIME type string, empty by default + # Register custom formats with MIME type mappings + # Use formats.add to register new format/MIME type pairs # - config.format custom: "application/custom" + config.formats.add :custom, "application/custom" - # Define a default format to set as `Content-Type` header for response, - # unless otherwise specified. - # If not defined here, it will return Rack's default: `application/octet-stream` - # Argument: symbol, it should be already known. defaults to `nil` + # Set accepted formats for this action + # Argument: format symbols, defaults to all formats # - config.default_response_format = :html + config.format :html, :json # Define a default charset to return in the `Content-Type` response header # If not defined here, it returns `utf-8` # Argument: string, defaults to `nil` # config.default_charset = "koi8-r" + + # Set default headers for all responses + # Argument: hash, empty by default + # + config.default_headers = {"X-Frame-Options" => "DENY"} + + # Set default cookie options for all responses + # Argument: hash, empty by default + # + config.cookies = { + domain: "hanamirb.org", + path: "/controller", + secure: true, + httponly: true + } + + # Set the root directory for the action (for file downloads) + # Defaults to current working directory + # Argument: string path + # + config.root_directory = "/path/to/root" + + # Set the public directory path (relative to root directory) + # Used for file downloads, defaults to "public" + # Argument: string path + # + config.public_directory = "assets" end ``` From 72995ce89ebc6cbc30e9d4d424db95bf04e7480c Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Fri, 19 Sep 2025 16:50:27 -0600 Subject: [PATCH 23/26] Use more popular class definition syntax --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9664e6b6..8d105245 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ They are useful for shared logic like authentication checks. require "hanami/controller" Article = Data.define(:title) -ArticleRepo = Class.new { def find(id) = Article.new(title: "Why Hanami? Reason ##{id}") } +class ArticleRepo; def find(id) = Article.new(title: "Why Hanami? Reason ##{id}"); end Data.define(:title) class BeforeCallbackMethodName < Hanami::Action @@ -335,7 +335,7 @@ Callbacks can also be expressed as anonymous lambdas: require "hanami/controller" Article = Data.define(:title) -ArticleRepo = Class.new { def find(id) = Article.new(title: "Why Hanami? Reason ##{id}") } +class ArticleRepo; def find(id) = Article.new(title: "Why Hanami? Reason ##{id}"); end class BeforeCallbackLambda < Hanami::Action before { } # do some authentication stuff @@ -379,7 +379,7 @@ You can map a specific raised exception to a different HTTP status. ```ruby require "hanami/controller" -RecordNotFound = Class.new(StandardError) +clsss RecordNotFound < StandardError; end class HandleCustomException < Hanami::Action handle_exception RecordNotFound => 404 From 2a17170cfa57707645e103ba862d42e6a0b7833b Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Fri, 19 Sep 2025 16:51:04 -0600 Subject: [PATCH 24/26] Use 'Action' as demo first name, instead of oblique reference --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8d105245..8c93819d 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ class Signup < Hanami::Action def handle(request, *) # :first_name is allowed, but not :admin is not - p request.params[:first_name] # => "Jericho" + p request.params[:first_name] # => "Action" p request.params[:admin] # => nil # :address's :line_one is allowed, but :line_two is not @@ -195,7 +195,7 @@ class Signup < Hanami::Action end end -Signup.new.call({first_name: "Jericho", admin: true, address: { line_one: "123 Motor City Blvd" }}) +Signup.new.call({first_name: "Action", admin: true, address: { line_one: "123 Motor City Blvd" }}) ``` #### Validations & Coercions @@ -231,7 +231,7 @@ end SignupValidateParams.new.call({}).status # => 400 SignupValidateParams.new.call({ - first_name: "Jericho", + first_name: "Action", last_name: "Jackson", email: "actionjackson@example.com", password: "password", From cfb8325d6c6423fa50ae51ba7b38dd6bd0028cd0 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Mon, 22 Sep 2025 18:26:06 -0600 Subject: [PATCH 25/26] Fix typos --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8c93819d..572d6753 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ action.call({}).class # => Hanami::Action::Response This is the same `response` object passed to `#handle`, where you can use its accessors to explicitly set status, headers, and body: ```ruby -class ManiplateResponse < Hanami::Action +class ManipulateResponse < Hanami::Action def handle(request, response) response.status = 201 response.body = "Hi!" @@ -286,7 +286,7 @@ class ExposeArticle < Hanami::Action end end -response = ExposeAticle.new.call(id: 23) +response = ExposeArticle.new.call(id: 23) p response[:article].class # => Article p response[:article].id # => 23 @@ -379,7 +379,7 @@ You can map a specific raised exception to a different HTTP status. ```ruby require "hanami/controller" -clsss RecordNotFound < StandardError; end +class RecordNotFound < StandardError; end class HandleCustomException < Hanami::Action handle_exception RecordNotFound => 404 From 08711ab962846fd66ea80d4dc7bb419040b12a3d Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Tue, 23 Sep 2025 15:42:10 -0600 Subject: [PATCH 26/26] Change requires to "hanami/action", add missing ones --- README.md | 74 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 572d6753..8ce1bc6b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ The core of this framework are the actions. They are the endpoints that respond to incoming HTTP requests. ```ruby -require "hanami/controller" +require "hanami/action" class HelloWorld < Hanami::Action def handle(request, response) @@ -78,6 +78,8 @@ __We're avoiding HTTP calls__, we're also going to avoid hitting the database (i Imagine how **fast** the unit test could be. ```ruby +require "hanami/action" + class ShowArticle < Hanami::Action def initialize(repo: ArticleRepo.new, **) @repo = repo @@ -107,7 +109,7 @@ There are three scenarios for how params are extracted: When routed with *Hanami::Router*, it extracts and merges route parameters, query string parameters, and form parameters (with router params taking precedence). ```ruby -require "hanami/controller" +require "hanami/action" class InspectParams < Hanami::Action def handle(request, response) @@ -127,7 +129,7 @@ InspectParams.new.call({ When used in a Rack application (but without Hanami::Router), it extracts query string and form parameters from the request. ```ruby -require "hanami/controller" +require "hanami/action" class ParamsFromRackInput < Hanami::Action def handle(request, response) @@ -147,7 +149,7 @@ ParamsFromRackInput.new.call({ When called directly with a hash (typical in unit tests), it returns the given hash as-is. ```ruby -require "hanami/controller" +require "hanami/action" class ParamsFromHash < Hanami::Action def handle(request, response) @@ -169,7 +171,7 @@ For security reasons it's recommended to use hanami-validations to validate the ```ruby require "hanami/validations" -require "hanami/controller" +require "hanami/action" class Signup < Hanami::Action params do @@ -208,7 +210,7 @@ If you specify the `:type` option, the param will be coerced. ```ruby require "hanami/validations" -require "hanami/controller" +require "hanami/action" class SignupValidateParams < Hanami::Action MEGABYTE = 1024 ** 2 @@ -245,7 +247,7 @@ SignupValidateParams.new.call({ The output of `#call` is a `Hanami::Action::Response` (which is a subclass of [Rack::Response](https://github.com/rack/rack/blob/main/lib/rack/response.rb)): ```ruby -require "hanami/controller" +require "hanami/action" class ReturnsResponse < Hanami::Action end @@ -257,6 +259,8 @@ action.call({}).class # => Hanami::Action::Response This is the same `response` object passed to `#handle`, where you can use its accessors to explicitly set status, headers, and body: ```ruby +require "hanami/action" + class ManipulateResponse < Hanami::Action def handle(request, response) response.status = 201 @@ -278,6 +282,8 @@ In case you need to send data from the action to other layers of your applicatio By default, an action exposes the request's params and the format. ```ruby +require "hanami/action" + Article = Data.define(:id) class ExposeArticle < Hanami::Action @@ -300,7 +306,7 @@ If you need to execute logic **before** or **after** `#handle` is invoked, you c They are useful for shared logic like authentication checks. ```ruby -require "hanami/controller" +require "hanami/action" Article = Data.define(:title) class ArticleRepo; def find(id) = Article.new(title: "Why Hanami? Reason ##{id}"); end @@ -332,7 +338,7 @@ p response[:article].title # => "Why Hanami? Reason #1000" Callbacks can also be expressed as anonymous lambdas: ```ruby -require "hanami/controller" +require "hanami/action" Article = Data.define(:title) class ArticleRepo; def find(id) = Article.new(title: "Why Hanami? Reason ##{id}"); end @@ -358,7 +364,7 @@ You can write custom exception handling on per action or configuration basis. An exception handler can be a valid HTTP status code (eg. `500`, `401`), or a `Symbol` that represents an action method. ```ruby -require "hanami/controller" +require "hanami/action" class HandleStandardError < Hanami::Action handle_exception StandardError => 500 @@ -377,7 +383,7 @@ p response.body # => ["Internal Server Error"] You can map a specific raised exception to a different HTTP status. ```ruby -require "hanami/controller" +require "hanami/action" class RecordNotFound < StandardError; end @@ -398,7 +404,7 @@ p response.body # ["Not Found"] You can also define custom handlers for exceptions. ```ruby -require "hanami/controller" +require "hanami/action" class CustomHandler < Hanami::Action handle_exception ArgumentError => :my_custom_handler @@ -427,7 +433,7 @@ p response.body # => ["Invalid arguments"] When `#halt` is used with a valid HTTP code, it stops the execution and sets the proper status and body for the response: ```ruby -require "hanami/controller" +require "hanami/action" class ThrowUnauthorized < Hanami::Action before :authenticate! @@ -456,7 +462,7 @@ p response.body # => ["Unauthorized"] Alternatively, you can specify a custom message to be used in the response body: ```ruby -require "hanami/controller" +require "hanami/action" class DroidRepo; def find(id) = nil; end; @@ -485,7 +491,7 @@ If you want to send cookies in the response, use `response.cookies`. They are read as a Hash on the request (using String keys), coming from the Rack env: ```ruby -require "hanami/controller" +require "hanami/action" class ReadCookiesFromRackEnv < Hanami::Action @@ -502,7 +508,7 @@ action.call({"HTTP_COOKIE" => "foo=bar"}) They are set like a Hash, once `include Hanami::Action::Cookies` is used: ```ruby -require "hanami/controller" +require "hanami/action" class SetCookies < Hanami::Action include Hanami::Action::Cookies @@ -520,7 +526,7 @@ action.call({}).headers.fetch("Set-Cookie") # "foo=bar" They are removed by setting their value to `nil`: ```ruby -require "hanami/controller" +require "hanami/action" class RemoveCookies < Hanami::Action include Hanami::Action::Cookies @@ -538,7 +544,7 @@ action.call({}).headers.fetch("Set-Cookie") # => "foo=; max-age=0; expires=Thu, Default values can be set in configuration, but overridden case by case. ```ruby -require "hanami/controller" +require "hanami/action" class SetCookies < Hanami::Action include Hanami::Action::Cookies @@ -565,7 +571,7 @@ Similarly to cookies, you can read the session sent by the HTTP client via `requ and manipulate it via `response.session`. ```ruby -require "hanami/controller" +require "hanami/action" class ReadSessionFromRackEnv < Hanami::Action include Hanami::Action::Session @@ -584,7 +590,7 @@ action.call({ "rack.session" => { "age" => "35" } }) Values can be set like a Hash: ```ruby -require "hanami/controller" +require "hanami/action" require "hanami/action/session" class SetSession < Hanami::Action @@ -605,7 +611,7 @@ response.session # => { age: 31 } Values can be removed like a Hash: ```ruby -require "hanami/controller" +require "hanami/action" require "hanami/action/session" class RemoveSession < Hanami::Action @@ -636,7 +642,7 @@ Hanami::Controller sets your headers correctly according to [RFC 2616, sec. 14.9 You can easily set the Cache-Control header for your actions: ```ruby -require "hanami/controller" +require "hanami/action" class HttpCache < Hanami::Action include Hanami::Action::Cache @@ -655,7 +661,7 @@ p response.headers.fetch("Cache-Control") # => "public, max-age=600" Expires header can be specified using `expires` method: ```ruby -require "hanami/controller" +require "hanami/action" class HttpCache < Hanami::Action include Hanami::Action::Cache @@ -688,7 +694,7 @@ Note that they way to use them in Rack is via the `"HTTP_IF_NONE_MATCH"` and `"H You can easily take advantage of Conditional Get using `#fresh` method. ```ruby -require "hanami/controller" +require "hanami/action" require "hanami/action/cache" Resource = Data.define(:cache_key) @@ -716,7 +722,7 @@ If the resource hasn’t been modified since the time specified in the `If-Modif the server responds with *304 Not Modified*. ```ruby -require "hanami/controller" +require "hanami/action" require "hanami/action/cache" Resource = Data.define(:updated_at) @@ -745,7 +751,7 @@ p second_response.status # => 304 If you need to redirect the client to another resource, use `response.redirect_to`: ```ruby -require "hanami/controller" +require "hanami/action" class RedirectAction < Hanami::Action def handle(request, response) @@ -762,7 +768,7 @@ p response.location # => "http://example.com/articles/23" (same as `response.hea You can also redirect with a custom status code: ```ruby -require "hanami/controller" +require "hanami/action" class PermanentRedirectAction < Hanami::Action def handle(request, response) @@ -781,7 +787,7 @@ p response.location # => "/articles/23" `Hanami::Action` automatically sets the `Content-Type` header, according to the request. ```ruby -require "hanami/controller" +require "hanami/action" class ResponseFormatAction < Hanami::Action def handle(request, response) @@ -802,7 +808,7 @@ p second_response.content_type # "text/html; charset=utf-8" However, you can force this value: ```ruby -require "hanami/controller" +require "hanami/action" class ForcedFormatAction < Hanami::Action def handle(request, response) @@ -825,7 +831,7 @@ p second_response.content_type # "application/json; charset=utf-8" You can restrict the accepted MIME types: ```ruby -require "hanami/controller" +require "hanami/action" class RestrictedTypesActionShow < Hanami::Action format :html, :json @@ -858,7 +864,7 @@ p xml_response.status # => 406 (Not Acceptable) You can check if the requested MIME type is accepted by the client. ```ruby -require "hanami/controller" +require "hanami/action" class CheckAcceptsAction < Hanami::Action def handle(request, response) @@ -888,7 +894,7 @@ Hanami::Controller ships with an extensive list of the most common MIME types. You can also register your own: ```ruby -require "hanami/controller" +require "hanami/action" class CustomFormatAcceptAction < Hanami::Action config.formats.add :custom, "application/custom" @@ -908,7 +914,7 @@ p response.content_type # => "application/custom; charset=utf-8" You can also manually set the format on the response: ```ruby -require "hanami/controller" +require "hanami/action" class ManualFormatAction < Hanami::Action config.formats.add :custom, "application/custom" @@ -936,6 +942,8 @@ You can set the body directly (see [response](#response)), use [Hanami::View](ht A Controller is nothing more than a logical group of actions: just a Ruby module. ```ruby +require "hanami/action" + module Articles class Index < Hanami::Action # ...