diff --git a/README.md b/README.md index b69bdfbe..8ce1bc6b 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/action" + +class HelloWorld < Hanami::Action def handle(request, response) - response[:article] = ArticleRepository.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. @@ -68,81 +73,105 @@ 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 - super(configuration: configuration) +require "hanami/action" + +class ShowArticle < Hanami::Action + def initialize(repo: ArticleRepo.new, **) + @repo = repo + super(**) 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 = ShowArticle.new(repo: ArticleRepo.new) 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, *) +require "hanami/action" + +class InspectParams < Hanami::Action + def handle(request, response) # ... - puts request.params # => { id: 23 } extracted from Rack env + 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" +InspectParams.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, *) +require "hanami/action" + +class ParamsFromRackInput < Hanami::Action + def handle(request, response) # ... - puts request.params # => { :"rack.version"=>[1, 2], :"rack.input"=>#, ... } + p request.params.to_h # => {name: "john", age: "25"} from query/form end end + +# When called with Rack env containing rack.input +ParamsFromRackInput.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, *) +require "hanami/action" + +class ParamsFromHash < Hanami::Action + def handle(request, response) # ... - puts request.params # => { id: 23, key: "value" } passed as it is from testing + p 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 = ParamsFromHash.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" -require "hanami/controller" +require "hanami/action" class Signup < Hanami::Action params do @@ -158,41 +187,39 @@ 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 - puts request.params[:first_name] # => "Luca" - puts request.params[:admin] # => nil + # :first_name is allowed, but not :admin is not + p request.params[:first_name] # => "Action" + p request.params[:admin] # => nil - # Whitelist nested params [:address][:line_one], not [:address][:line_two] - puts request.params[:address][:line_one] # => "69 Tender St" - puts request.params[:address][:line_two] # => nil + # :address's :line_one is allowed, but :line_two is not + p request.params[:address][:line_one] # => "123 Motor City Blvd" + p request.params[:address][:line_two] # => nil end end + +Signup.new.call({first_name: "Action", 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. ```ruby require "hanami/validations" -require "hanami/controller" +require "hanami/action" -class Signup < Hanami::Action +class SignupValidateParams < Hanami::Action MEGABYTE = 1024 ** 2 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)) @@ -203,55 +230,74 @@ class Signup < Hanami::Action # ... end end + +SignupValidateParams.new.call({}).status # => 400 +SignupValidateParams.new.call({ + first_name: "Action", + 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`: +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/action" + +class ReturnsResponse < Hanami::Action end -action = Show.new(configuration: configuration) -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 - def handle(*, response) +require "hanami/action" + +class ManipulateResponse < Hanami::Action + def handle(request, response) response.status = 201 response.body = "Hi!" response.headers.merge!("X-Custom" => "OK") end end -action = Show.new -action.call({}) # => [201, { "X-Custom" => "OK" }, ["Hi!"]] +action = ManipulateResponse.new +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. -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 -class Show < Hanami::Action +require "hanami/action" + +Article = Data.define(:id) + +class ExposeArticle < Hanami::Action def handle(request, response) - response[:article] = ArticleRepository.new.find(request.params[:id]) + response[:article] = Article.new(id: request.params[:id]) end end -action = Show.new(configuration: configuration) -response = action.call(id: 23) +response = ExposeArticle.new.call(id: 23) -article = response[:article] -article.class # => Article -article.id # => 23 +p response[:article].class # => Article +p response[:article].id # => 23 -response.exposures.keys # => [:params, :article] +p response.exposures.keys # => [:article, :params, :format] ``` ### Callbacks @@ -260,10 +306,16 @@ 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/action" + +Article = Data.define(:title) +class ArticleRepo; def find(id) = Article.new(title: "Why Hanami? Reason ##{id}"); end + +Data.define(:title) +class BeforeCallbackMethodName < Hanami::Action before :authenticate, :set_article - def handle(*) + def handle(request, response) end private @@ -274,21 +326,34 @@ 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 + +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 - before { |request, response| response[:article] = ArticleRepository.new.find(request.params[:id]) } +require "hanami/action" - def handle(*) +Article = Data.define(:title) +class ArticleRepo; def find(id) = Article.new(title: "Why Hanami? Reason ##{id}"); end + +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 @@ -299,40 +364,52 @@ 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/action" + +class HandleStandardError < 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 = HandleStandardError.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 -class Show < Hanami::Action +require "hanami/action" + +class RecordNotFound < StandardError; end + +class HandleCustomException < 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 = HandleCustomException.new +response = action.call({}) +p response.status # => 404 +p response.body # ["Not Found"] ``` You can also define custom handlers for exceptions. ```ruby -class Create < Hanami::Action +require "hanami/action" + +class CustomHandler < Hanami::Action handle_exception ArgumentError => :my_custom_handler - def handle(*) + def handle(request, response) raise ArgumentError.new("Invalid arguments") end @@ -344,77 +421,24 @@ class Create < Hanami::Action end end -action = Create.new(configuration: configuration) -action.call({}) # => [400, {}, ["Invalid arguments"]] +action = CustomHandler.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 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/action" + +class ThrowUnauthorized < Hanami::Action before :authenticate! - def handle(*) + def handle(request, response) # ... end @@ -423,18 +447,28 @@ 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 = ThrowUnauthorized.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 Show < Hanami::Action +require "hanami/action" + +class DroidRepo; def find(id) = nil; end; + +class FindDroid < 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 @@ -444,8 +478,9 @@ class Show < Hanami::Action end end -action = Show.new(configuration: configuration) -action.call({}) # => [404, {}, ["This is not the droid you're looking for"]] +response = FindDroid.new.call({}) +p response.status # => 404 +p response.body # => ["This is not the droid you're looking for"] ``` ### Cookies @@ -453,145 +488,143 @@ action.call({}) # => [404, {}, ["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" +require "hanami/action" class ReadCookiesFromRackEnv < Hanami::Action - include Hanami::Action::Cookies - def handle(request, *) + def handle(request, response) # ... - request.cookies[:foo] # => "bar" + p 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" +require "hanami/action" 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" +require "hanami/action" 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 +require "hanami/action" 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" +require "hanami/action" class ReadSessionFromRackEnv < Hanami::Action include Hanami::Action::Session + config.session = { expire_after: 3600 } def handle(request, *) # ... - request.session[:age] # => "35" + p request.session[:age] # => "35" end end -action = ReadSessionFromRackEnv.new(configuration: configuration) +action = ReadSessionFromRackEnv.new 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 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: ```ruby -require "hanami/controller" +require "hanami/action" 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**. @@ -599,117 +632,154 @@ 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 -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: ```ruby -require "hanami/controller" -require "hanami/action/cache" +require "hanami/action" -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({}) +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/cache" +require "hanami/action" -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 -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" 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 -An alternative to hashing based check, is the time based check: +second_response = ConditionalGetEtag.new.call({"HTTP_IF_NONE_MATCH" => "abc123"}) +p second_response.status # => 304 +``` + +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" 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 If you need to redirect the client to another resource, use `response.redirect_to`: ```ruby -class Create < Hanami::Action - def handle(*, response) +require "hanami/action" + +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/action" + +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 @@ -717,149 +787,163 @@ action.call({ article: { title: "Hello" }}) # => [301, {"Location" => "/articles `Hanami::Action` automatically sets the `Content-Type` header, according to the request. ```ruby -class Show < Hanami::Action - def handle(*) +require "hanami/action" + +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/action" + +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/action" - 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 -``` -You can check if the requested MIME type is accepted by the client. +action = RestrictedTypesActionShow.new -```ruby -class Show < Hanami::Action - def handle(request, response) - # ... - # @_env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9" +any_format_response = action.call({ "HTTP_ACCEPT" => "*/*" }) +p any_format_response.status # => 200 +p any_format_response.format # :html (since it was listed first) - request.accept?("text/html") # => true - request.accept?("application/xml") # => true - request.accept?("application/json") # => false - response.format # :html +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 - # @_env["HTTP_ACCEPT"] # => "*/*" +xml_response = action.call({ "HTTP_ACCEPT" => "application/xml" }) +p xml_response.status # => 406 (Not Acceptable) - request.accept?("text/html") # => true - request.accept?("application/xml") # => true - request.accept?("application/json") # => true - response.format # :html - end -end ``` -Hanami::Controller is shipped with an extensive list of the most common MIME types. -Also, you can register your own: +You can check if the requested MIME type is accepted by the client. ```ruby -configuration = Hanami::Controller::Configuration.new do |config| - config.format custom: "application/custom" -end +require "hanami/action" -class Index < Hanami::Action - def handle(*) +class CheckAcceptsAction < Hanami::Action + def handle(request, response) + # ... + # request.env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9" + + 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 -action = Index.new(configuration: configuration) +action = CheckAcceptsAction.new -response = action.call({ "HTTP_ACCEPT" => "application/custom" }) # => Content-Type "application/custom" -response.format # => :custom +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" => "*/*" }) +``` -class Show < Hanami::Action - def handle(*, response) - # ... - response.format = :custom +#### Custom Formats + +Hanami::Controller ships with an extensive list of the most common MIME types. +You can also register your own: + +```ruby +require "hanami/action" + +class CustomFormatAcceptAction < Hanami::Action + config.formats.add :custom, "application/custom" + + def handle(*) end end -action = Show.new(configuration: configuration) +action = CustomFormatAcceptAction.new -response = action.call({ "HTTP_ACCEPT" => "*/*" }) # => 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" ``` -### 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. +You can also manually set the format on the response: ```ruby -configuration = Hanami::Controller::Configuration.new do |config| - config.format csv: 'text/csv' -end +require "hanami/action" -class Csv < Hanami::Action - def handle(*, response) - response.format = :csv - response.body = Enumerator.new do |yielder| - yielder << csv_header +class ManualFormatAction < Hanami::Action + config.formats.add :custom, "application/custom" - # Expensive operation is streamed as each line becomes available - csv_body.each_line do |line| - yielder << line - end - end + def handle(request, response) + # ... + response.format = :custom 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 +action = ManualFormatAction.new + +response = action.call({ "HTTP_ACCEPT" => "*/*" }) +p response.format # => :custom +p response.content_type # => "application/custom; charset=utf-8" +``` ### 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 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 # ... @@ -870,30 +954,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 @@ -902,36 +963,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 ```