require 'hanami/utils/string' require 'hanami/utils/class' require 'hanami/routing/endpoint' module Hanami module Routing # Resolve duck-typed endpoints # # @since 0.1.0 # # @api private class EndpointResolver # @since 0.2.0 # @api private NAMING_PATTERN = '%{controller}::%{action}'.freeze # @since 0.7.0 # @api private DEFAULT_RESPONSE = [404, {'X-Cascade' => 'pass'}, 'Not Found'].freeze # Default separator for controller and action. # A different separator can be passed to #initialize with the `:separator` option. # # @see #initialize # @see #resolve # # @since 0.1.0 # # @example # require 'hanami/router' # # router = Hanami::Router.new do # get '/', to: 'articles#show' # end ACTION_SEPARATOR = '#'.freeze attr_reader :action_separator # Initialize an endpoint resolver # # @param options [Hash] the options used to customize lookup behavior # # @option options [Class] :endpoint the endpoint class that is returned # by `#resolve`. (defaults to `Hanami::Routing::Endpoint`) # # @option options [Class,Module] :namespace the Ruby namespace where to # lookup for controllers and actions. (defaults to `Object`) # # @option options [String] :pattern the string to interpolate in order # to return an action name. This string SHOULD contain # '%{controller}' and '%{action}', all the other keys # will be ignored. # See the examples below. # # @option options [String] :action_separator the separator between controller and # action name. (defaults to `ACTION_SEPARATOR`) # # @return [Hanami::Routing::EndpointResolver] self # # @since 0.1.0 # # @example Specify custom endpoint class # require 'hanami/router' # # resolver = Hanami::Routing::EndpointResolver.new(endpoint: CustomEndpoint) # router = Hanami::Router.new(resolver: resolver) # # router.get('/', to: endpoint).dest # => # # # @example Specify custom Ruby namespace # require 'hanami/router' # # resolver = Hanami::Routing::EndpointResolver.new(namespace: MyApp) # router = Hanami::Router.new(resolver: resolver) # # router.get('/', to: 'articles#show') # # => Will look for: MyApp::Articles::Show # # # # @example Specify custom pattern # require 'hanami/router' # # resolver = Hanami::Routing::EndpointResolver.new(pattern: '%{controller}Controller::%{action}') # router = Hanami::Router.new(resolver: resolver) # # router.get('/', to: 'articles#show') # # => Will look for: ArticlesController::Show # # # # @example Specify custom controller-action separator # require 'hanami/router' # # resolver = Hanami::Routing::EndpointResolver.new(separator: '@') # router = Hanami::Router.new(resolver: resolver) # # router.get('/', to: 'articles@show') # # => Will look for: Articles::Show def initialize(options = {}) @endpoint_class = options[:endpoint] || Endpoint @namespace = options[:namespace] || Object @action_separator = options[:action_separator] || ACTION_SEPARATOR @pattern = options[:pattern] || NAMING_PATTERN end # Resolve the given set of HTTP verb, path, endpoint and options. # If it fails to resolve, it will mount the default endpoint to the given # path, which returns an 404 (Not Found). # # @param options [Hash] the options required to resolve the endpoint # # @option options [String,Proc,Class,Object#call] :to the endpoint # @option options [String] :namespace an optional routing namespace # # @return [Endpoint] this may vary according to the :endpoint option # passed to #initialize # # @since 0.1.0 # # @see #initialize # @see #find # # @example Resolve to a Proc # require 'hanami/router' # # router = Hanami::Router.new # router.get '/', to: ->(env) { [200, {}, ['Hi!']] } # # @example Resolve to a class # require 'hanami/router' # # router = Hanami::Router.new # router.get '/', to: RackMiddleware # # @example Resolve to a Rack compatible object (respond to #call) # require 'hanami/router' # # router = Hanami::Router.new # router.get '/', to: AnotherMiddleware.new # # @example Resolve to a Hanami::Action from a string (see Hanami::Controller framework) # require 'hanami/router' # # router = Hanami::Router.new # router.get '/', to: 'articles#show' # # @example Resolve to a Hanami::Action (see Hanami::Controller framework) # require 'hanami/router' # # router = Hanami::Router.new # router.get '/', to: Articles::Show # # @example Resolve a redirect with a namespace # require 'hanami/router' # # router = Hanami::Router.new # router.namespace 'users' do # get '/home', to: ->(env) { ... } # redirect '/dashboard', to: '/home' # end # # # GET /users/dashboard => 301 Location: "/users/home" def resolve(options, &endpoint) result = endpoint || find(options) resolve_callable(result) || resolve_matchable(result) || default end # Finds a path from the given options. # # @param options [Hash] the path description # @option options [String,Proc,Class,Object#call] :to the endpoint # @option options [String] :namespace an optional namespace # # @since 0.1.0 # # @return [Object] def find(options) options[:to] end protected def default @endpoint_class.new( ->(env) { DEFAULT_RESPONSE } ) end def constantize(string) klass = Utils::Class.load!(string, @namespace) if klass.respond_to?(:call) Endpoint.new(klass) else ClassEndpoint.new(klass) end rescue NameError LazyEndpoint.new(string, @namespace) end def classify(string) Utils::String.new(string).underscore.classify end private def resolve_callable(callable) if callable.respond_to?(:call) @endpoint_class.new(callable) elsif callable.is_a?(Class) && callable.instance_methods.include?(:call) @endpoint_class.new(callable.new) end end def resolve_matchable(matchable) if matchable.respond_to?(:match) constantize( resolve_action(matchable) || classify(matchable) ) end end def resolve_action(string) if string.match(action_separator) controller, action = string.split(action_separator).map {|token| classify(token) } @pattern % {controller: controller, action: action} end end end end end