629 lines
17 KiB
Ruby
629 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "dry/configurable"
|
|
require "hanami/utils"
|
|
require "hanami/utils/callbacks"
|
|
require "hanami/utils/kernel"
|
|
require "hanami/utils/string"
|
|
require "rack"
|
|
require "rack/utils"
|
|
require "zeitwerk"
|
|
|
|
require_relative "action/constants"
|
|
require_relative "action/errors"
|
|
|
|
module Hanami
|
|
# An HTTP endpoint
|
|
#
|
|
# @since 0.1.0
|
|
#
|
|
# @example
|
|
# require "hanami/controller"
|
|
#
|
|
# class Show < Hanami::Action
|
|
# def handle(req, res)
|
|
# # ...
|
|
# end
|
|
# end
|
|
#
|
|
# @api public
|
|
class Action
|
|
# @since 2.0.0
|
|
# @api private
|
|
def self.gem_loader
|
|
@gem_loader ||= Zeitwerk::Loader.new.tap do |loader|
|
|
root = File.expand_path("..", __dir__)
|
|
loader.tag = "hanami-controller"
|
|
loader.inflector = Zeitwerk::GemInflector.new("#{root}/hanami-controller.rb")
|
|
loader.push_dir(root)
|
|
loader.ignore(
|
|
"#{root}/hanami-controller.rb",
|
|
"#{root}/hanami/controller/version.rb",
|
|
"#{root}/hanami/action/{constants,errors,params,validatable}.rb"
|
|
)
|
|
loader.inflector.inflect("csrf_protection" => "CSRFProtection")
|
|
end
|
|
end
|
|
|
|
gem_loader.setup
|
|
|
|
# Make conditional requires after Zeitwerk setup so any internal autoloading works as expected
|
|
begin
|
|
require "hanami/validations"
|
|
require_relative "action/validatable"
|
|
rescue LoadError # rubocop:disable Lint/SuppressedException
|
|
end
|
|
|
|
extend Dry::Configurable(config_class: Config)
|
|
|
|
# See {Config} for individual setting accessor API docs
|
|
setting :handled_exceptions, default: {}
|
|
setting :formats, default: Config::DEFAULT_FORMATS
|
|
setting :default_request_format, constructor: -> (format) {
|
|
Utils::Kernel.Symbol(format) unless format.nil?
|
|
}
|
|
setting :default_response_format, constructor: -> (format) {
|
|
Utils::Kernel.Symbol(format) unless format.nil?
|
|
}
|
|
setting :accepted_formats, default: []
|
|
setting :default_charset
|
|
setting :default_headers, default: {}, constructor: -> (headers) { headers.compact }
|
|
setting :cookies, default: {}, constructor: -> (cookie_options) {
|
|
# Call `to_h` here to permit `ApplicationConfiguration::Cookies` object to be
|
|
# provided when application actions are configured
|
|
cookie_options.to_h.compact
|
|
}
|
|
setting :root_directory, constructor: -> (dir) {
|
|
Pathname(File.expand_path(dir || Dir.pwd)).realpath
|
|
}
|
|
setting :public_directory, default: Config::DEFAULT_PUBLIC_DIRECTORY
|
|
setting :before_callbacks, default: Utils::Callbacks::Chain.new, cloneable: true
|
|
setting :after_callbacks, default: Utils::Callbacks::Chain.new, cloneable: true
|
|
|
|
# @!scope class
|
|
|
|
# @!method config
|
|
# Returns the action's config. Use this to configure your action.
|
|
#
|
|
# @example Access inside class body
|
|
# class Show < Hanami::Action
|
|
# config.default_response_format = :json
|
|
# end
|
|
#
|
|
# @return [Config]
|
|
#
|
|
# @api public
|
|
# @since 2.0.0
|
|
|
|
# @!scope instance
|
|
|
|
# Override Ruby's hook for modules.
|
|
# It includes basic Hanami::Action modules to the given class.
|
|
#
|
|
# @param subclass [Class] the target action
|
|
#
|
|
# @since 0.1.0
|
|
# @api private
|
|
def self.inherited(subclass)
|
|
super
|
|
|
|
if subclass.superclass == Action
|
|
subclass.class_eval do
|
|
include Validatable if defined?(Validatable)
|
|
end
|
|
end
|
|
|
|
if instance_variable_defined?(:@params_class)
|
|
subclass.instance_variable_set(:@params_class, @params_class)
|
|
end
|
|
end
|
|
|
|
# Returns the class which defines the params
|
|
#
|
|
# Returns the class which has been provided to define the
|
|
# params. By default this will be Hanami::Action::Params.
|
|
#
|
|
# @return [Class] A params class (when whitelisted) or
|
|
# Hanami::Action::Params
|
|
#
|
|
# @api private
|
|
# @since 0.7.0
|
|
def self.params_class
|
|
@params_class || BaseParams
|
|
end
|
|
|
|
# Placeholder implementation for params class method
|
|
#
|
|
# Raises a developer friendly error to include `hanami/validations`.
|
|
#
|
|
# @raise [NoMethodError]
|
|
#
|
|
# @api public
|
|
# @since 2.0.0
|
|
def self.params(_klass = nil)
|
|
raise NoMethodError,
|
|
"To use `params`, please add 'hanami/validations' gem to your Gemfile"
|
|
end
|
|
|
|
# @overload self.append_before(*callbacks, &block)
|
|
# Define a callback for an Action.
|
|
# The callback will be executed **before** the action is called, in the
|
|
# order they are added.
|
|
#
|
|
# @param callbacks [Symbol, Array<Symbol>] a single or multiple symbol(s)
|
|
# each of them is representing a name of a method available in the
|
|
# context of the Action.
|
|
#
|
|
# @param blk [Proc] an anonymous function to be executed
|
|
#
|
|
# @return [void]
|
|
#
|
|
# @since 0.3.2
|
|
#
|
|
# @see Hanami::Action::Callbacks::ClassMethods#append_after
|
|
#
|
|
# @example Method names (symbols)
|
|
# require "hanami/controller"
|
|
#
|
|
# class Show < Hanami::Action
|
|
# before :authenticate, :set_article
|
|
#
|
|
# def handle(req, res)
|
|
# end
|
|
#
|
|
# private
|
|
# def authenticate
|
|
# # ...
|
|
# end
|
|
#
|
|
# # `params` in the method signature is optional
|
|
# def set_article(params)
|
|
# @article = Article.find params[:id]
|
|
# end
|
|
# end
|
|
#
|
|
# # The order of execution will be:
|
|
# #
|
|
# # 1. #authenticate
|
|
# # 2. #set_article
|
|
# # 3. #call
|
|
#
|
|
# @example Anonymous functions (Procs)
|
|
# require "hanami/controller"
|
|
#
|
|
# class Show < Hanami::Action
|
|
# before { ... } # 1 do some authentication stuff
|
|
# before {|req, res| @article = Article.find params[:id] } # 2
|
|
#
|
|
# def handle(req, res)
|
|
# end
|
|
# end
|
|
#
|
|
# # The order of execution will be:
|
|
# #
|
|
# # 1. authentication
|
|
# # 2. set the article
|
|
# # 3. `#handle`
|
|
def self.append_before(...)
|
|
config.before_callbacks.append(...)
|
|
end
|
|
|
|
class << self
|
|
# @since 0.1.0
|
|
alias_method :before, :append_before
|
|
end
|
|
|
|
# @overload self.append_after(*callbacks, &block)
|
|
# Define a callback for an Action.
|
|
# The callback will be executed **after** the action is called, in the
|
|
# order they are added.
|
|
#
|
|
# @param callbacks [Symbol, Array<Symbol>] a single or multiple symbol(s)
|
|
# each of them is representing a name of a method available in the
|
|
# context of the Action.
|
|
#
|
|
# @param blk [Proc] an anonymous function to be executed
|
|
#
|
|
# @return [void]
|
|
#
|
|
# @since 0.3.2
|
|
#
|
|
# @see Hanami::Action::Callbacks::ClassMethods#append_before
|
|
def self.append_after(...)
|
|
config.after_callbacks.append(...)
|
|
end
|
|
|
|
class << self
|
|
# @since 0.1.0
|
|
alias_method :after, :append_after
|
|
end
|
|
|
|
# @overload self.prepend_before(*callbacks, &block)
|
|
# Define a callback for an Action.
|
|
# The callback will be executed **before** the action is called.
|
|
# It will add the callback at the beginning of the callbacks' chain.
|
|
#
|
|
# @param callbacks [Symbol, Array<Symbol>] a single or multiple symbol(s)
|
|
# each of them is representing a name of a method available in the
|
|
# context of the Action.
|
|
#
|
|
# @param blk [Proc] an anonymous function to be executed
|
|
#
|
|
# @return [void]
|
|
#
|
|
# @since 0.3.2
|
|
#
|
|
# @see Hanami::Action::Callbacks::ClassMethods#prepend_after
|
|
def self.prepend_before(...)
|
|
config.before_callbacks.prepend(...)
|
|
end
|
|
|
|
# @overload self.prepend_after(*callbacks, &block)
|
|
# Define a callback for an Action.
|
|
# The callback will be executed **after** the action is called.
|
|
# It will add the callback at the beginning of the callbacks' chain.
|
|
#
|
|
# @param callbacks [Symbol, Array<Symbol>] a single or multiple symbol(s)
|
|
# each of them is representing a name of a method available in the
|
|
# context of the Action.
|
|
#
|
|
# @param blk [Proc] an anonymous function to be executed
|
|
#
|
|
# @return [void]
|
|
#
|
|
# @since 0.3.2
|
|
#
|
|
# @see Hanami::Action::Callbacks::ClassMethods#prepend_before
|
|
def self.prepend_after(...)
|
|
config.after_callbacks.prepend(...)
|
|
end
|
|
|
|
# Restrict the access to the specified mime type symbols.
|
|
#
|
|
# @param formats[Array<Symbol>] one or more symbols representing mime type(s)
|
|
#
|
|
# @raise [Hanami::Action::UnknownFormatError] if the symbol cannot
|
|
# be converted into a mime type
|
|
#
|
|
# @since 0.1.0
|
|
#
|
|
# @see Config#format
|
|
#
|
|
# @example
|
|
# require "hanami/controller"
|
|
#
|
|
# class Show < Hanami::Action
|
|
# accept :html, :json
|
|
#
|
|
# def handle(req, res)
|
|
# # ...
|
|
# end
|
|
# end
|
|
#
|
|
# # When called with "*/*" => 200
|
|
# # When called with "text/html" => 200
|
|
# # When called with "application/json" => 200
|
|
# # When called with "application/xml" => 415
|
|
def self.accept(*formats)
|
|
config.accepted_formats = formats
|
|
end
|
|
|
|
# @see Config#handle_exception
|
|
#
|
|
# @since 2.0.0
|
|
# @api public
|
|
def self.handle_exception(...)
|
|
config.handle_exception(...)
|
|
end
|
|
|
|
# Returns a new action
|
|
#
|
|
# @since 2.0.0
|
|
# @api public
|
|
def initialize(config: self.class.config)
|
|
@config = config
|
|
freeze
|
|
end
|
|
|
|
# Implements the Rack/Hanami::Action protocol
|
|
#
|
|
# @since 0.1.0
|
|
# @api private
|
|
def call(env)
|
|
request = nil
|
|
response = nil
|
|
|
|
halted = catch :halt do
|
|
params = self.class.params_class.new(env)
|
|
request = build_request(
|
|
env: env,
|
|
params: params,
|
|
sessions_enabled: sessions_enabled?
|
|
)
|
|
response = build_response(
|
|
request: request,
|
|
config: config,
|
|
content_type: Mime.calculate_content_type_with_charset(config, request, config.accepted_mime_types),
|
|
env: env,
|
|
headers: config.default_headers,
|
|
sessions_enabled: sessions_enabled?
|
|
)
|
|
|
|
enforce_accepted_mime_types(request)
|
|
|
|
_run_before_callbacks(request, response)
|
|
handle(request, response)
|
|
_run_after_callbacks(request, response)
|
|
rescue StandardError => exception
|
|
_handle_exception(request, response, exception)
|
|
end
|
|
|
|
finish(request, response, halted)
|
|
end
|
|
|
|
protected
|
|
|
|
# Hook for subclasses to apply behavior as part of action invocation
|
|
#
|
|
# @param request [Hanami::Action::Request]
|
|
# @param response [Hanami::Action::Response]
|
|
#
|
|
# @since 2.0.0
|
|
# @api public
|
|
def handle(request, response)
|
|
end
|
|
|
|
# Halt the action execution with the given HTTP status code and message.
|
|
#
|
|
# When used, the execution of a callback or of an action is interrupted
|
|
# and the control returns to the framework, that decides how to handle
|
|
# the event.
|
|
#
|
|
# If a message is provided, it sets the response body with the message.
|
|
# Otherwise, it sets the response body with the default message associated
|
|
# to the code (eg 404 will set `"Not Found"`).
|
|
#
|
|
# @param status [Fixnum] a valid HTTP status code
|
|
# @param body [String] the response body
|
|
#
|
|
# @raise [StandardError] if the code isn't valid
|
|
#
|
|
# @since 0.2.0
|
|
#
|
|
# @see Hanami::Action::Throwable#handle_exception
|
|
# @see Hanami::Http::Status:ALL
|
|
#
|
|
# @example Basic usage
|
|
# require "hanami/controller"
|
|
#
|
|
# class Show < Hanami::Action
|
|
# def handle(*)
|
|
# halt 404
|
|
# end
|
|
# end
|
|
#
|
|
# # => [404, {}, ["Not Found"]]
|
|
#
|
|
# @example Custom message
|
|
# require "hanami/controller"
|
|
#
|
|
# class Show < Hanami::Action
|
|
# def handle(*)
|
|
# halt 404, "This is not the droid you're looking for."
|
|
# end
|
|
# end
|
|
#
|
|
# # => [404, {}, ["This is not the droid you're looking for."]]
|
|
def halt(status, body = nil)
|
|
Halt.call(status, body)
|
|
end
|
|
|
|
# @since 0.3.2
|
|
# @api private
|
|
def _requires_no_body?(res)
|
|
HTTP_STATUSES_WITHOUT_BODY.include?(res.status)
|
|
end
|
|
|
|
# @since 2.0.0
|
|
# @api private
|
|
alias_method :_requires_empty_headers?, :_requires_no_body?
|
|
|
|
private
|
|
|
|
# @since 2.0.0
|
|
# @api private
|
|
attr_reader :config
|
|
|
|
# @since 2.0.0
|
|
# @api private
|
|
def enforce_accepted_mime_types(request)
|
|
return if config.accepted_formats.empty?
|
|
|
|
Mime.enforce_accept(request, config) { return halt 406 }
|
|
Mime.enforce_content_type(request, config) { return halt 415 }
|
|
end
|
|
|
|
# @since 2.0.0
|
|
# @api private
|
|
def exception_handler(exception)
|
|
config.handled_exceptions.each do |exception_class, handler|
|
|
return handler if exception.is_a?(exception_class)
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
# @see Session#sessions_enabled?
|
|
# @since 2.0.0
|
|
# @api private
|
|
def sessions_enabled?
|
|
false
|
|
end
|
|
|
|
# Hook to be overridden by `Hanami::Extensions::Action` for integrated actions
|
|
#
|
|
# @since 2.0.0
|
|
# @api private
|
|
def build_request(**options)
|
|
Request.new(**options)
|
|
end
|
|
|
|
# Hook to be overridden by `Hanami::Extensions::Action` for integrated actions
|
|
#
|
|
# @since 2.0.0
|
|
# @api private
|
|
def build_response(**options)
|
|
Response.new(**options)
|
|
end
|
|
|
|
# @since 0.2.0
|
|
# @api private
|
|
def _reference_in_rack_errors(req, exception)
|
|
req.env[RACK_EXCEPTION] = exception
|
|
|
|
if errors = req.env[RACK_ERRORS]
|
|
errors.write(_dump_exception(exception))
|
|
errors.flush
|
|
end
|
|
end
|
|
|
|
# @since 0.2.0
|
|
# @api private
|
|
def _dump_exception(exception)
|
|
[[exception.class, exception.message].compact.join(": "), *exception.backtrace].join("\n\t")
|
|
end
|
|
|
|
# @since 0.1.0
|
|
# @api private
|
|
def _handle_exception(req, res, exception)
|
|
handler = exception_handler(exception)
|
|
|
|
if handler.nil?
|
|
_reference_in_rack_errors(req, exception)
|
|
raise exception
|
|
end
|
|
|
|
instance_exec(
|
|
req,
|
|
res,
|
|
exception,
|
|
&_exception_handler(handler)
|
|
)
|
|
|
|
nil
|
|
end
|
|
|
|
# @since 0.3.0
|
|
# @api private
|
|
def _exception_handler(handler)
|
|
if respond_to?(handler.to_s, true)
|
|
method(handler)
|
|
else
|
|
->(*) { halt handler }
|
|
end
|
|
end
|
|
|
|
# @since 0.1.0
|
|
# @api private
|
|
def _run_before_callbacks(*args)
|
|
config.before_callbacks.run(self, *args)
|
|
nil
|
|
end
|
|
|
|
# @since 0.1.0
|
|
# @api private
|
|
def _run_after_callbacks(*args)
|
|
config.after_callbacks.run(self, *args)
|
|
nil
|
|
end
|
|
|
|
# According to RFC 2616, when a response MUST have an empty body, it only
|
|
# allows Entity Headers.
|
|
#
|
|
# For instance, a <tt>204</tt> doesn't allow <tt>Content-Type</tt> or any
|
|
# other custom header.
|
|
#
|
|
# This restriction is enforced by <tt>Hanami::Action#_requires_no_body?</tt>.
|
|
#
|
|
# However, there are cases that demand to bypass this rule to set meta
|
|
# informations via headers.
|
|
#
|
|
# An example is a <tt>DELETE</tt> request for a JSON API application.
|
|
# It returns a <tt>204</tt> but still wants to specify the rate limit
|
|
# quota via <tt>X-Rate-Limit</tt>.
|
|
#
|
|
# @since 0.5.0
|
|
#
|
|
# @see Hanami::Action#_requires_no_body?
|
|
#
|
|
# @example
|
|
# require "hanami/controller"
|
|
#
|
|
# module Books
|
|
# class Destroy < Hanami::Action
|
|
# def handle(*, res)
|
|
# # ...
|
|
# res.headers.merge!(
|
|
# "Last-Modified" => "Fri, 27 Nov 2015 13:32:36 GMT",
|
|
# "X-Rate-Limit" => "4000",
|
|
# "Content-Type" => "application/json",
|
|
# "X-No-Pass" => "true"
|
|
# )
|
|
#
|
|
# res.status = 204
|
|
# end
|
|
#
|
|
# private
|
|
#
|
|
# def keep_response_header?(header)
|
|
# super || header == "X-Rate-Limit"
|
|
# end
|
|
# end
|
|
# end
|
|
#
|
|
# # Only the following headers will be sent:
|
|
# # * Last-Modified - because we used `super' in the method that respects the HTTP RFC
|
|
# # * X-Rate-Limit - because we explicitely allow it
|
|
#
|
|
# # Both Content-Type and X-No-Pass are removed because they're not allowed
|
|
def keep_response_header?(header)
|
|
ENTITY_HEADERS.include?(header)
|
|
end
|
|
|
|
# @since 2.0.0
|
|
# @api private
|
|
def _empty_headers(res)
|
|
res.headers.select! { |header, _| keep_response_header?(header) }
|
|
end
|
|
|
|
# @since 2.0.0
|
|
# @api private
|
|
def _empty_body(res)
|
|
res.body = Response::EMPTY_BODY
|
|
end
|
|
|
|
# Finalize the response
|
|
#
|
|
# Prepare the data before the response will be returned to the webserver
|
|
#
|
|
# @since 0.1.0
|
|
# @api private
|
|
# @abstract
|
|
#
|
|
# @see Hanami::Action::Session#finish
|
|
# @see Hanami::Action::Cookies#finish
|
|
# @see Hanami::Action::Cache#finish
|
|
def finish(req, res, halted)
|
|
res.status, res.body = *halted unless halted.nil?
|
|
|
|
_empty_headers(res) if _requires_empty_headers?(res)
|
|
_empty_body(res) if res.head?
|
|
|
|
res.set_format(Action::Mime.detect_format(res.content_type, config))
|
|
res[:params] = req.params
|
|
res[:format] = res.format
|
|
res
|
|
end
|
|
end
|
|
end
|