Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/hanami/controller
Complete, fast and testable actions for Rack and Hanami
https://github.com/hanami/controller
api controller hanami http rack ruby
Last synced: about 1 month ago
JSON representation
Complete, fast and testable actions for Rack and Hanami
- Host: GitHub
- URL: https://github.com/hanami/controller
- Owner: hanami
- License: mit
- Created: 2013-06-25T08:20:55.000Z (over 11 years ago)
- Default Branch: main
- Last Pushed: 2024-05-15T11:20:20.000Z (8 months ago)
- Last Synced: 2024-05-23T00:27:57.160Z (7 months ago)
- Topics: api, controller, hanami, http, rack, ruby
- Language: Ruby
- Homepage: http://hanamirb.org
- Size: 1.54 MB
- Stars: 245
- Watchers: 26
- Forks: 108
- Open Issues: 9
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Funding: .github/FUNDING.yml
- License: LICENSE.md
- Code of conduct: CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# Hanami::Controller
Complete, fast, and testable actions for Rack and [Hanami](http://hanamirb.org)
## Status
[![Gem Version](https://badge.fury.io/rb/hanami-controller.svg)](https://badge.fury.io/rb/hanami-controller)
[![CI](https://github.com/hanami/controller/actions/workflows/ci.yml/badge.svg)](https://github.com/hanami/controller/actions?query=workflow%3Aci+branch%3Amain)
[![Test Coverage](https://codecov.io/gh/hanami/controller/branch/main/graph/badge.svg)](https://codecov.io/gh/hanami/controller)
[![Depfu](https://badges.depfu.com/badges/7cd17419fba78b726be1353118fb01de/overview.svg)](https://depfu.com/github/hanami/controller?project=Bundler)## Contact
* Home page: http://hanamirb.org
* Community: http://hanamirb.org/community
* Guides: https://guides.hanamirb.org
* Mailing List: http://hanamirb.org/mailing-list
* API Doc: http://rubydoc.info/gems/hanami-controller
* Chat: http://chat.hanamirb.org## Installation
__Hanami::Controller__ supports Ruby (MRI) 3.1+
Add this line to your application's Gemfile:
```ruby
gem "hanami-controller"
```And then execute:
```shell
$ bundle
```Or install it yourself as:
```shell
$ gem install hanami-controller
```## Usage
Hanami::Controller is a micro library for web frameworks.
It works beautifully with [Hanami::Router](https://github.com/hanami/router), but it can be employed everywhere.
It's designed to be fast and testable.### Actions
The core of this framework are the actions.
They are the endpoints that respond to incoming HTTP requests.```ruby
class Show < Hanami::Action
def handle(req, res)
res[:article] = ArticleRepository.new.find(req.params[:id])
end
end
````Hanami::Action` follows the Hanami philosophy: a single purpose object with a minimal interface.
In this case, `Hanami::Action` provides the key public interface of `#call(env)`, making your actions Rack-compatible.
To provide custom behaviour when your actions are being called, you can implement `#handle(req, res)`**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__.
Imagine how **fast** the unit test could be.```ruby
class Show < Hanami::Action
def initialize(configuration:, repository: ArticleRepository.new)
@repository = repository
super(configuration: configuration)
enddef handle(req, res)
res[:article] = repository.find(req.params[:id])
endprivate
attr_reader :repository
endconfiguration = Hanami::Controller::Configuration.new
action = Show.new(configuration: configuration, repository: ArticleRepository.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` (eg 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`:
```ruby
class Show < Hanami::Action
def handle(req, *)
# ...
puts req.params # => { id: 23 } extracted from Rack env
end
end
```Standalone:
```ruby
class Show < Hanami::Action
def handle(req, *)
# ...
puts req.params # => { :"rack.version"=>[1, 2], :"rack.input"=>#, ... }
end
end
```Unit Testing:
```ruby
class Show < Hanami::Action
def handle(req, *)
# ...
puts req.params # => { id: 23, key: "value" } passed as it is from testing
end
endaction = Show.new(configuration: configuration)
response = action.call(id: 23, key: "value")
```#### Whitelisting
Params represent an untrusted input.
For security reasons it's recommended to whitelist them.```ruby
require "hanami/validations"
require "hanami/controller"class Signup < Hanami::Action
params do
required(:first_name).filled(:str?)
required(:last_name).filled(:str?)
required(:email).filled(:str?)required(:address).schema do
required(:line_one).filled(:str?)
required(:state).filled(:str?)
required(:country).filled(:str?)
end
enddef handle(req, *)
# Describe inheritance hierarchy
puts req.params.class # => Signup::Params
puts req.params.class.superclass # => Hanami::Action::Params# Whitelist :first_name, but not :admin
puts req.params[:first_name] # => "Luca"
puts req.params[:admin] # => nil# Whitelist nested params [:address][:line_one], not [:address][:line_two]
puts req.params[:address][:line_one] # => "69 Tender St"
puts req.params[:address][:line_two] # => nil
end
end
```#### 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.If you specify the `:type` option, the param will be coerced.
```ruby
require "hanami/validations"
require "hanami/controller"class Signup < Hanami::Action
MEGABYTE = 1024 ** 2params do
required(:first_name).filled(:str?)
required(:last_name).filled(:str?)
required(:email).filled?(:str?, format?: /\A.+@.+\z/)
required(:password).filled(:str?).confirmation
required(:terms_of_service).filled(:bool?)
required(:age).filled(:int?, included_in?: 18..99)
optional(:avatar).filled(size?: 1..(MEGABYTE * 3))
enddef handle(req, *)
halt 400 unless req.params.valid?
# ...
end
end
```### Response
The output of `#call` is a `Hanami::Action::Response`:
```ruby
class Show < Hanami::Action
endaction = Show.new(configuration: configuration)
action.call({}) # => #
```This is the same `res` 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(*, res)
res.status = 201
res.body = "Hi!"
res.headers.merge!("X-Custom" => "OK")
end
endaction = Show.new
action.call({}) # => [201, { "X-Custom" => "OK" }, ["Hi!"]]
```### 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.```ruby
class Show < Hanami::Action
def handle(req, res)
res[:article] = ArticleRepository.new.find(req.params[:id])
end
endaction = Show.new(configuration: configuration)
response = action.call(id: 23)article = response[:article]
article.class # => Article
article.id # => 23response.exposures.keys # => [:params, :article]
```### Callbacks
If you need to execute logic **before** or **after** `#handle` is invoked, you can use _callbacks_.
They are useful for shared logic like authentication checks.```ruby
class Show < Hanami::Action
before :authenticate, :set_articledef handle(*)
endprivate
def authenticate
# ...
end# `req` and `res` in the method signature is optional
def set_article(req, res)
res[:article] = ArticleRepository.new.find(req.params[:id])
end
end
```Callbacks can also be expressed as anonymous lambdas:
```ruby
class Show < Hanami::Action
before { ... } # do some authentication stuff
before { |req, res| res[:article] = ArticleRepository.new.find(req.params[:id]) }def handle(*)
end
end
```### Exceptions management
When the app raises an exception, `hanami-controller`, does **NOT** manage it.
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
handle_exception StandardError => 500def handle(*)
raise
end
endaction = Show.new(configuration: configuration)
action.call({}) # => [500, {}, ["Internal Server Error"]]
```You can map a specific raised exception to a different HTTP status.
```ruby
class Show < Hanami::Action
handle_exception RecordNotFound => 404def handle(*)
raise RecordNotFound
end
endaction = Show.new(configuration: configuration)
action.call({}) # => [404, {}, ["Not Found"]]
```You can also define custom handlers for exceptions.
```ruby
class Create < Hanami::Action
handle_exception ArgumentError => :my_custom_handlergle(*)
raise ArgumentError.new("Invalid arguments")
endprivate
def my_custom_handler(req, res, exception)
res.status = 400
res.body = exception.message
end
endaction = Create.new(configuration: configuration)
action.call({}) # => [400, {}, ["Invalid arguments"]]
```Exception policies can be defined globally via configuration:
```ruby
configuration = Hanami::Controller::Configuration.new do |config|
config.handle_exception RecordNotFound => 404
endclass Show < Hanami::Action
def handle(*)
raise RecordNotFound
end
endaction = Show.new(configuration: configuration)
action.call({}) # => [404, {}, ["Not Found"]]
```#### Inherited Exceptions
```ruby
class MyCustomException < StandardError
endmodule Articles
class Index < Hanami::Action
handle_exception MyCustomException => :handle_my_exceptiondef handle(*)
raise MyCustomException
endprivate
def handle_my_exception(req, res, exception)
# ...
end
endclass Show < Hanami::Action
handle_exception StandardError => :handle_standard_errordef handle(*)
raise MyCustomException
endprivate
def handle_standard_error(req, res, exception)
# ...
end
end
endArticles::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
before :authenticate!def handle(*)
# ...
endprivate
def authenticate!
halt 401 unless authenticated?
end
endaction = Show.new(configuration: configuration)
action.call({}) # => [401, {}, ["Unauthorized"]]
```Alternatively, you can specify a custom message.
```ruby
class Show < Hanami::Action
def handle(req, res)
res[:droid] = DroidRepository.new.find(req.params[:id]) or not_found
endprivate
def not_found
halt 404, "This is not the droid you're looking for"
end
endaction = Show.new(configuration: configuration)
action.call({}) # => [404, {}, ["This is not the droid you're looking for"]]
```### Cookies
You can read the original cookies sent from the HTTP client via `req.cookies`.
If you want to send cookies in the response, use `res.cookies`.They are read as a Hash from Rack env:
```ruby
require "hanami/controller"
require "hanami/action/cookies"class ReadCookiesFromRackEnv < Hanami::Action
include Hanami::Action::Cookiesdef handle(req, *)
# ...
req.cookies[:foo] # => "bar"
end
endaction = ReadCookiesFromRackEnv.new(configuration: configuration)
action.call({"HTTP_COOKIE" => "foo=bar"})
```They are set like a Hash:
```ruby
require "hanami/controller"
require "hanami/action/cookies"class SetCookies < Hanami::Action
include Hanami::Action::Cookiesdef handle(*, res)
# ...
res.cookies[:foo] = "bar"
end
endaction = SetCookies.new(configuration: configuration)
action.call({}) # => [200, {"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::Cookiesdef handle(*, res)
# ...
res.cookies[:foo] = nil
end
endaction = RemoveCookies.new(configuration: configuration)
action.call({}) # => [200, {"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
endclass SetCookies < Hanami::Action
include Hanami::Action::Cookiesdef handle(*, res)
# ...
res.cookies[:foo] = { value: "bar", max_age: 100 }
end
endaction = SetCookies.new(configuration: configuration)
action.call({}) # => [200, {"Set-Cookie" => "foo=bar; max-age=100;"}, "..."]
```### Sessions
Actions have builtin support for Rack sessions.
Similarly to cookies, you can read the session sent by the HTTP client via
`req.session`, and also manipulate it via `res.ression`.```ruby
require "hanami/controller"
require "hanami/action/session"class ReadSessionFromRackEnv < Hanami::Action
include Hanami::Action::Sessiondef handle(req, *)
# ...
req.session[:age] # => "35"
end
endaction = ReadSessionFromRackEnv.new(configuration: configuration)
action.call({ "rack.session" => { "age" => "35" } })
```Values can be set like a Hash:
```ruby
require "hanami/controller"
require "hanami/action/session"class SetSession < Hanami::Action
include Hanami::Action::Sessiondef handle(*, res)
# ...
res.session[:age] = 31
end
endaction = SetSession.new(configuration: configuration)
action.call({}) # => [200, {"Set-Cookie"=>"rack.session=..."}, "..."]
```Values can be removed like a Hash:
```ruby
require "hanami/controller"
require "hanami/action/session"class RemoveSession < Hanami::Action
include Hanami::Action::Sessiondef handle(*, res)
# ...
res.session[:age] = nil
end
endaction = RemoveSession.new(configuration: configuration)
action.call({}) # => [200, {"Set-Cookie"=>"rack.session=..."}, "..."] it removes that value from the session
```While Hanami::Controller supports sessions natively, it's **session store agnostic**.
You have to specify the session store in your Rack middleware configuration (eg `config.ru`).```ruby
use Rack::Session::Cookie, secret: SecureRandom.hex(64)
run Show.new(configuration: configuration)
```### 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
You can easily set the Cache-Control header for your actions:
```ruby
require "hanami/controller"
require "hanami/action/cache"class HttpCacheController < Hanami::Action
include Hanami::Action::Cache
cache_control :public, max_age: 600 # => Cache-Control: public, max-age=600def handle(*)
# ...
end
end
```Expires header can be specified using `expires` method:
```ruby
require "hanami/controller"
require "hanami/action/cache"class HttpCacheController < 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=600def handle(*)
# ...
end
end
```### 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.
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.
You can easily take advantage of Conditional Get using `#fresh` method:
```ruby
require "hanami/controller"
require "hanami/action/cache"class ConditionalGetController < Hanami::Action
include Hanami::Action::Cachedef handle(*)
# ...
fresh etag: resource.cache_key
# => halt 304 with header IfNoneMatch = resource.cache_key
end
end
```If `resource.cache_key` is equal to `IfNoneMatch` header, then hanami will `halt 304`.
An alterative to hashing based check, is the time based check:
```ruby
require "hanami/controller"
require "hanami/action/cache"class ConditionalGetController < Hanami::Action
include Hanami::Action::Cachedef handle(*)
# ...
fresh last_modified: resource.update_at
# => halt 304 with header IfModifiedSince = resource.update_at.httpdate
end
end
```If `resource.update_at` is equal to `IfModifiedSince` header, then hanami will `halt 304`.
### Redirect
If you need to redirect the client to another resource, use `res.redirect_to`:
```ruby
class Create < Hanami::Action
def handle(*, res)
# ...
res.redirect_to "http://example.com/articles/23"
end
endaction = Create.new(configuration: configuration)
action.call({ article: { title: "Hello" }}) # => [302, {"Location" => "/articles/23"}, ""]
```You can also redirect with a custom status code:
```ruby
class Create < Hanami::Action
def handle(*, res)
# ...
res.redirect_to "http://example.com/articles/23", status: 301
end
endaction = Create.new(configuration: configuration)
action.call({ article: { title: "Hello" }}) # => [301, {"Location" => "/articles/23"}, ""]
```### MIME Types
`Hanami::Action` automatically sets the `Content-Type` header, according to the request.
```ruby
class Show < Hanami::Action
def handle(*)
end
endaction = Show.new(configuration: configuration)
response = action.call({ "HTTP_ACCEPT" => "*/*" }) # Content-Type "application/octet-stream"
response.format # :allresponse = action.call({ "HTTP_ACCEPT" => "text/html" }) # Content-Type "text/html"
response.format # :html
```However, you can force this value:
```ruby
class Show < Hanami::Action
def handle(*, res)
# ...
res.format = :json
end
endaction = Show.new(configuration: configuration)
response = action.call({ "HTTP_ACCEPT" => "*/*" }) # Content-Type "application/json"
response.format # :jsonresponse = action.call({ "HTTP_ACCEPT" => "text/html" }) # Content-Type "application/json"
response.format # :json
```You can restrict the accepted MIME types:
```ruby
class Show < Hanami::Action
accept :html, :jsondef handle(*)
# ...
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.
```ruby
class Show < Hanami::Action
def handle(req, res)
# ...
# @_env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9"req.accept?("text/html") # => true
req.accept?("application/xml") # => true
req.accept?("application/json") # => false
res.format # :html# @_env["HTTP_ACCEPT"] # => "*/*"
req.accept?("text/html") # => true
req.accept?("application/xml") # => true
req.accept?("application/json") # => true
res.format # :html
end
end
```Hanami::Controller is shipped with an extensive list of the most common MIME types.
Also, you can register your own:```ruby
configuration = Hanami::Controller::Configuration.new do |config|
config.format custom: "application/custom"
endclass Index < Hanami::Action
def handle(*)
end
endaction = Index.new(configuration: configuration)
response = action.call({ "HTTP_ACCEPT" => "application/custom" }) # => Content-Type "application/custom"
response.format # => :customclass Show < Hanami::Action
def handle(*, res)
# ...
res.format = :custom
end
endaction = Show.new(configuration: configuration)
response = action.call({ "HTTP_ACCEPT" => "*/*" }) # => Content-Type "application/custom"
response.format # => :custom
```### 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'
endclass Csv < Hanami::Action
def handle(*, res)
res.format = :csv
res.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).### Controllers
A Controller is nothing more than a logical group of actions: just a Ruby module.
```ruby
module Articles
class Index < Hanami::Action
# ...
endclass Show < Hanami::Action
# ...
end
endArticles::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
endconfiguration = Hanami::Controller::Configuration.new
router = Hanami::Router.new(configuration: configuration, namespace: Web::Controllers) do
get "/books/:id", "books#show"
end
```### Rack integration
Hanami::Controller is compatible with Rack. If you need to use any Rack middleware, please mount them in `config.ru`.
### Configuration
Hanami::Controller can be configured via `Hanami::Controller::Configuration`.
It supports a few options:```ruby
require "hanami/controller"configuration = Hanami::Controller::Configuration.new do |config|
# 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# Register a format to MIME type mapping
# Argument: hash, key: format symbol, value: MIME type string, empty by default
#
config.format 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`
#
config.default_response_format = :html# 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"
end
```### Thread safety
An Action is **immutable**, it works without global state, so it's thread-safe by design.
## Versioning
__Hanami::Controller__ uses [Semantic Versioning 2.0.0](http://semver.org)
## Contributing
1. Fork it
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request## Copyright
Copyright © 2014–2024 Hanami Team – Released under MIT License