require 'securerandom'
require 'hanami/action/request'
require 'hanami/action/base_params'
require 'hanami/action/rack/callable'
require 'hanami/action/rack/file'
module Hanami
module Action
# Rack integration API
#
# @since 0.1.0
module Rack
# Rack SPEC response code
#
# @since 1.0.0.beta2
# @api private
RESPONSE_CODE = 0
# Rack SPEC response headers
#
# @since 1.0.0.beta2
# @api private
RESPONSE_HEADERS = 1
# Rack SPEC response body
#
# @since 1.0.0.beta2
# @api private
RESPONSE_BODY = 2
# The default HTTP response code
#
# @since 0.1.0
# @api private
DEFAULT_RESPONSE_CODE = 200
# Not Found
#
# @since 1.0.0.beta2
# @api private
NOT_FOUND = 404
# The default Rack response body
#
# @since 0.1.0
# @api private
DEFAULT_RESPONSE_BODY = []
# The default HTTP Request ID length
#
# @since 0.3.0
# @api private
#
# @see Hanami::Action::Rack#request_id
DEFAULT_REQUEST_ID_LENGTH = 16
# The request method
#
# @since 0.3.2
# @api private
REQUEST_METHOD = 'REQUEST_METHOD'.freeze
# The Content-Length HTTP header
#
# @since 1.0.0.beta2
# @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.beta2
# @api private
X_CASCADE = 'X-Cascade'.freeze
# HEAD request
#
# @since 0.3.2
# @api private
HEAD = 'HEAD'.freeze
# The key that returns router parsed body from the Rack env
ROUTER_PARSED_BODY = 'router.parsed_body'.freeze
# Override Ruby's hook for modules.
# It includes basic Hanami::Action modules to the given class.
#
# @param base [Class] the target action
#
# @since 0.1.0
# @api private
#
# @see http://www.ruby-doc.org/core-2.1.2/Module.html#method-i-included
def self.included(base)
base.class_eval do
extend ClassMethods
prepend InstanceMethods
end
end
module ClassMethods
# Build rack builder
#
# @return [Rack::Builder]
def rack_builder
@rack_builder ||= begin
extend Hanami::Action::Rack::Callable
rack_builder = ::Rack::Builder.new
rack_builder.run ->(env) { self.new.call(env) }
rack_builder
end
end
# Use a Rack middleware
#
# The middleware will be used as it is.
#
# At the runtime, the middleware be invoked with the raw Rack env.
#
# Multiple middlewares can be employed, just by using multiple times
# this method.
#
# @param middleware [#call] A Rack middleware
# @param args [Array] Array arguments for middleware
#
# @since 0.2.0
#
# @see Hanami::Action::Callbacks::ClassMethods#before
#
# @example Middleware
# require 'hanami/controller'
#
# module Sessions
# class Create
# include Hanami::Action
# use OmniAuth
#
# def call(params)
# # ...
# end
# end
# end
def use(middleware, *args, &block)
rack_builder.use middleware, *args, &block
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 params_class
@params_class ||= BaseParams
end
end
# @since 0.7.0
# @api private
module InstanceMethods
# @since 0.7.0
# @api private
def initialize(*)
super
@_status = nil
@_body = nil
end
end
protected
# Gets the headers from the response
#
# @return [Hash] the HTTP headers from the response
#
# @since 0.1.0
#
# @example
# require 'hanami/controller'
#
# class Show
# include Hanami::Action
#
# def call(params)
# # ...
# self.headers # => { ... }
# self.headers.merge!({'X-Custom' => 'OK'})
# end
# end
def headers
@headers
end
# Returns a serialized Rack response (Array), according to the current
# status code, headers, and body.
#
# @return [Array] the serialized response
#
# @since 0.1.0
# @api private
#
# @see Hanami::Action::Rack::DEFAULT_RESPONSE_CODE
# @see Hanami::Action::Rack::DEFAULT_RESPONSE_BODY
# @see Hanami::Action::Rack#status=
# @see Hanami::Action::Rack#headers
# @see Hanami::Action::Rack#body=
def response
[ @_status || DEFAULT_RESPONSE_CODE, headers, @_body || DEFAULT_RESPONSE_BODY.dup ]
end
# Calculates an unique ID for the current request
#
# @return [String] The unique ID
#
# @since 0.3.0
def request_id
# FIXME make this number configurable and document the probabilities of clashes
@request_id ||= SecureRandom.hex(DEFAULT_REQUEST_ID_LENGTH)
end
# Returns a Hanami specialized rack request
#
# @return [Hanami::Action::Request] The request
#
# @since 0.3.1
#
# @example
# require 'hanami/controller'
#
# class Create
# include Hanami::Action
#
# def call(params)
# ip = request.ip
# secure = request.ssl?
# end
# end
def request
@request ||= ::Hanami::Action::Request.new(@_env)
end
def parsed_request_body
@_env.fetch(ROUTER_PARSED_BODY, nil)
end
private
# Sets the HTTP status code for the response
#
# @param status [Fixnum] an HTTP status code
# @return [void]
#
# @since 0.1.0
#
# @example
# require 'hanami/controller'
#
# class Create
# include Hanami::Action
#
# def call(params)
# # ...
# self.status = 201
# end
# end
def status=(status)
@_status = status
end
# Sets the body of the response
#
# @param body [String] the body of the response
# @return [void]
#
# @since 0.1.0
#
# @example
# require 'hanami/controller'
#
# class Show
# include Hanami::Action
#
# def call(params)
# # ...
# self.body = 'Hi!'
# end
# end
def body=(body)
body = Array(body) unless body.respond_to?(:each)
@_body = body
end
# Send a file as response.
# This method only sends files from the public directory
#
# It automatically handle the following cases:
#
# * Content-Type and Content-Length
# * File Not found (returns a 404)
# * Conditional GET (via If-Modified-Since header)
# * Range requests (via Range header)
#
# @param path [String, Pathname] the body of the response
# @return [void]
#
# @since 0.4.3
#
# @example
# require 'hanami/controller'
#
# class Show
# include Hanami::Action
#
# def call(params)
# # ...
# send_file Pathname.new('path/to/file')
# end
# end
def send_file(path)
_send_file(
File.new(path, self.class.configuration.public_directory).call(@_env)
)
end
# Send a file as response from anywhere in the file system.
#
# @see Hanami::Action::Rack#send_file
#
# @param path [String, Pathname] path to the file to be sent
# @return [void]
#
# @since 1.0.0.beta2
#
# @example
# require 'hanami/controller'
#
# class Show
# include Hanami::Action
#
# def call(params)
# # ...
# unsafe_send_file Pathname.new('/tmp/path/to/file')
# end
# end
def unsafe_send_file(path)
directory = self.class.configuration.root_directory if Pathname.new(path).relative?
_send_file(
File.new(path, directory).call(@_env)
)
end
# Check if the current request is a HEAD
#
# @return [TrueClass,FalseClass] the result of the check
#
# @since 0.3.2
def head?
request_method == HEAD
end
# NOTE: Hanami::Action::CSRFProtection (hanamirb gem) depends on this.
#
# @api private
# @since 0.4.4
def request_method
@_env[REQUEST_METHOD]
end
# @since 1.0.0.beta2
# @api private
def _send_file(response)
headers.merge!(response[RESPONSE_HEADERS])
if response[RESPONSE_CODE] == NOT_FOUND
headers.delete(X_CASCADE)
headers.delete(CONTENT_LENGTH)
halt NOT_FOUND
else
halt response[RESPONSE_CODE], response[RESPONSE_BODY]
end
end
end
end
end