begin require 'hanami/validations' require 'hanami/action/validatable' rescue LoadError end require 'hanami/utils/class_attribute' require 'hanami/utils/callbacks' require 'hanami/utils' require 'hanami/utils/string' require 'hanami/utils/kernel' require 'rack/utils' require_relative 'action/base_params' require_relative 'action/configuration' require_relative 'action/halt' require_relative 'action/mime' require_relative 'action/rack/file' require_relative 'action/request' require_relative 'action/response' module Hanami # An HTTP endpoint # # @since 0.1.0 # # @example # require 'hanami/controller' # # class Show # include Hanami::Action # # def call(params) # # ... # end # end class Action # Rack SPEC response code # # @since 1.0.0 # @api private RESPONSE_CODE = 0 # Rack SPEC response headers # # @since 1.0.0 # @api private RESPONSE_HEADERS = 1 # Rack SPEC response body # # @since 1.0.0 # @api private RESPONSE_BODY = 2 DEFAULT_ERROR_CODE = 500 # Status codes that by RFC must not include a message body # # @since 0.3.2 # @api private HTTP_STATUSES_WITHOUT_BODY = Set.new((100..199).to_a << 204 << 205 << 304).freeze # Not Found # # @since 1.0.0 # @api private NOT_FOUND = 404 # Entity headers allowed in blank body responses, according to # RFC 2616 - Section 10 (HTTP 1.1). # # "The response MAY include new or updated metainformation in the form # of entity-headers". # # @since 0.4.0 # @api private # # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.5 # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html ENTITY_HEADERS = { 'Allow' => true, 'Content-Encoding' => true, 'Content-Language' => true, 'Content-Location' => true, 'Content-MD5' => true, 'Content-Range' => true, 'Expires' => true, 'Last-Modified' => true, 'extension-header' => true }.freeze # The request method # # @since 0.3.2 # @api private REQUEST_METHOD = 'REQUEST_METHOD'.freeze # The Content-Length HTTP header # # @since 1.0.0 # @api private CONTENT_LENGTH = 'Content-Length'.freeze # The non-standard HTTP header to pass the control over when a resource # cannot be found by the current endpoint # # @since 1.0.0 # @api private X_CASCADE = 'X-Cascade'.freeze # HEAD request # # @since 0.3.2 # @api private HEAD = 'HEAD'.freeze # The key that returns accepted mime types from the Rack env # # @since 0.1.0 # @api private HTTP_ACCEPT = 'HTTP_ACCEPT'.freeze # The header key to set the mime type of the response # # @since 0.1.0 # @api private CONTENT_TYPE = 'Content-Type'.freeze # The default mime type for an incoming HTTP request # # @since 0.1.0 # @api private DEFAULT_ACCEPT = '*/*'.freeze # The default mime type that is returned in the response # # @since 0.1.0 # @api private DEFAULT_CONTENT_TYPE = 'application/octet-stream'.freeze # @since 0.2.0 # @api private RACK_ERRORS = 'rack.errors'.freeze # This isn't part of Rack SPEC # # Exception notifiers use rack.exception instead of # rack.errors, so we need to support it. # # @since 0.5.0 # @api private # # @see Hanami::Action::Throwable::RACK_ERRORS # @see http://www.rubydoc.info/github/rack/rack/file/SPEC#The_Error_Stream # @see https://github.com/hanami/controller/issues/133 RACK_EXCEPTION = 'rack.exception'.freeze # The HTTP header for redirects # # @since 0.2.0 # @api private LOCATION = 'Location'.freeze # 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) if subclass.superclass == Action subclass.class_eval do include Utils::ClassAttribute class_attribute :before_callbacks self.before_callbacks = Utils::Callbacks::Chain.new class_attribute :after_callbacks self.after_callbacks = Utils::Callbacks::Chain.new include Validatable if defined?(Validatable) end end subclass.instance_variable_set '@configuration', configuration.dup end def self.configuration @configuration ||= Configuration.new end class << self alias_method :config, :configuration 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 # FIXME: make this thread-safe def self.accepted_formats @accepted_formats ||= [] end # 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] 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 # include Hanami::Action # # before :authenticate, :set_article # # def call(params) # 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 # include Hanami::Action # # before { ... } # 1 do some authentication stuff # before {|params| @article = Article.find params[:id] } # 2 # # def call(params) # end # end # # # The order of execution will be: # # # # 1. authentication # # 2. set the article # # 3. #call def self.append_before(*callbacks, &blk) before_callbacks.append(*callbacks, &blk) end class << self # @since 0.1.0 alias_method :before, :append_before end # 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] 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(*callbacks, &blk) after_callbacks.append(*callbacks, &blk) end class << self # @since 0.1.0 alias_method :after, :append_after end # 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] 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(*callbacks, &blk) before_callbacks.prepend(*callbacks, &blk) end # 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] 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(*callbacks, &blk) after_callbacks.prepend(*callbacks, &blk) end # Restrict the access to the specified mime type symbols. # # @param formats[Array] one or more symbols representing mime type(s) # # @raise [Hanami::Controller::UnknownFormatError] if the symbol cannot # be converted into a mime type # # @since 0.1.0 # # @see Hanami::Controller::Configuration#format # # @example # require 'hanami/controller' # # class Show # include Hanami::Action # accept :html, :json # # def call(params) # # ... # end # end # # # When called with "*/*" => 200 # # When called with "text/html" => 200 # # When called with "application/json" => 200 # # When called with "application/xml" => 406 def self.accept(*formats) @accepted_formats = *formats before :enforce_accepted_mime_types end # Returns a new action # # @overload new(**deps, ...) # @param deps [Hash] action dependencies # # @overload new(configuration:, **deps, ...) # @param configuration [Hanami::Controller::Configuration] action configuration # @param deps [Hash] action dependencies # # @return [Hanami::Action] Action object # # @since 2.0.0 def self.new(*args, configuration: self.configuration, **kwargs, &block) allocate.tap do |obj| obj.instance_variable_set(:@name, Name[name]) obj.instance_variable_set(:@configuration, configuration.dup.finalize!) obj.instance_variable_set(:@accepted_mime_types, Mime.restrict_mime_types(configuration, accepted_formats)) obj.send(:initialize, *args, **kwargs, &block) obj.freeze end end module Name MODULE_SEPARATOR_TRANSFORMER = [:gsub, "::", "."].freeze def self.call(name) Utils::String.transform(name, MODULE_SEPARATOR_TRANSFORMER, :underscore) unless name.nil? end class << self alias_method :[], :call end end attr_reader :name # Implements the Rack/Hanami::Action protocol # # @since 0.1.0 # @api private def call(env) request = nil response = nil halted = catch :halt do begin params = self.class.params_class.new(env) request = build_request(env, params) response = build_response( request: request, action: name, configuration: configuration, content_type: Mime.calculate_content_type_with_charset(configuration, request, accepted_mime_types), env: env, headers: configuration.default_headers ) _run_before_callbacks(request, response) handle(request, response) _run_after_callbacks(request, response) rescue => exception _handle_exception(request, response, exception) end end finish(request, response, halted) end def initialize(**deps) @_deps = deps 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 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 # def call(params) # halt 404 # end # end # # # => [404, {}, ["Not Found"]] # # @example Custom message # require 'hanami/controller' # # class Show # def call(params) # 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 def _requires_empty_headers?(res) _requires_no_body?(res) || res.head? end private attr_reader :configuration def accepted_mime_types @accepted_mime_types || configuration.mime_types end def enforce_accepted_mime_types(req, *) Mime.accepted_mime_type?(req, accepted_mime_types, configuration) or halt 406 end def exception_handler(exception) configuration.handled_exceptions.each do |exception_class, handler| return handler if exception.kind_of?(exception_class) end nil end def build_request(env, params) Request.new(env, params) end 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) self.class.before_callbacks.run(self, *args) nil end # @since 0.1.0 # @api private def _run_after_callbacks(*args) self.class.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 204 doesn't allow Content-Type or any # other custom header. # # This restriction is enforced by Hanami::Action#_requires_no_body?. # # However, there are cases that demand to bypass this rule to set meta # informations via headers. # # An example is a DELETE request for a JSON API application. # It returns a 204 but still wants to specify the rate limit # quota via X-Rate-Limit. # # @since 0.5.0 # # @see Hanami::Action#_requires_no_body? # # @example # require 'hanami/controller' # # module Books # class Destroy # include Hanami::Action # # def call(params) # # ... # self.headers.merge!( # 'Last-Modified' => 'Fri, 27 Nov 2015 13:32:36 GMT', # 'X-Rate-Limit' => '4000', # 'Content-Type' => 'application/json', # 'X-No-Pass' => 'true' # ) # # self.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 def format(value) case value when Symbol format = Utils::Kernel.Symbol(value) [format, Action::Mime.format_to_mime_type(format, configuration)] when String [Action::Mime.detect_format(value, configuration), value] else raise Hanami::Controller::UnknownFormatError.new(value) end 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) res.set_format(Action::Mime.detect_format(res.content_type, configuration)) res[:params] = req.params res[:format] = res.format res end end end