lib/hanami/router.rb in hanami-router-2.0.0.alpha1 vs lib/hanami/router.rb in hanami-router-2.0.0.alpha2

- old
+ new

@@ -1,1056 +1,475 @@ # frozen_string_literal: true -require "rack/request" -require "dry/inflector" -require "hanami/routing" -require "hanami/utils/hash" +require "rack/utils" -# Hanami -# -# @since 0.1.0 module Hanami # Rack compatible, lightweight and fast HTTP Router. # # @since 0.1.0 - # - # @example It offers an intuitive DSL, that supports most of the HTTP verbs: - # require 'hanami/router' - # - # endpoint = ->(env) { [200, {}, ['Welcome to Hanami::Router!']] } - # router = Hanami::Router.new do - # get '/', to: endpoint # => get and head requests - # post '/', to: endpoint - # put '/', to: endpoint - # patch '/', to: endpoint - # delete '/', to: endpoint - # options '/', to: endpoint - # trace '/', to: endpoint - # end - # - # - # - # @example Specify an endpoint with `:to` (Rack compatible object) - # require 'hanami/router' - # - # endpoint = ->(env) { [200, {}, ['Welcome to Hanami::Router!']] } - # router = Hanami::Router.new do - # get '/', to: endpoint - # end - # - # # :to is mandatory for the default resolver (`Hanami::Routing::EndpointResolver.new`), - # # This behavior can be changed by passing a custom resolver to `Hanami::Router#initialize` - # - # - # - # @example Specify an endpoint with `:to` (controller and action string) - # require 'hanami/router' - # - # router = Hanami::Router.new do - # get '/', to: 'articles#show' # => Articles::Show - # end - # - # # This is a builtin feature for a Hanami::Controller convention. - # - # - # - # @example Specify a named route with `:as` - # require 'hanami/router' - # - # endpoint = ->(env) { [200, {}, ['Welcome to Hanami::Router!']] } - # router = Hanami::Router.new(scheme: 'https', host: 'hanamirb.org') do - # get '/', to: endpoint, as: :root - # end - # - # router.path(:root) # => '/' - # router.url(:root) # => 'https://hanamirb.org/' - # - # # This isn't mandatory for the default route class (`Hanami::Routing::Route`), - # # This behavior can be changed by passing a custom route to `Hanami::Router#initialize` - # - # @example Mount an application - # require 'hanami/router' - # - # router = Hanami::Router.new do - # mount Api::App, at: '/api' - # end - # - # # All the requests starting with "/api" will be forwarded to Api::App - # class Router # rubocop:disable Metrics/ClassLength - # @since 2.0.0 - # @api private - attr_reader :inflector + require "hanami/router/version" + require "hanami/router/error" + require "hanami/router/segment" + require "hanami/router/redirect" + require "hanami/router/prefix" + require "hanami/router/params" + require "hanami/router/trie" + require "hanami/router/block" + require "hanami/router/url_helpers" - # This error is raised when <tt>#call</tt> is invoked on a non-routable - # recognized route. + # URL helpers for other Hanami integrations # - # @since 0.5.0 - # - # @see Hanami::Router#recognize - # @see Hanami::Routing::RecognizedRoute - # @see Hanami::Routing::RecognizedRoute#call - # @see Hanami::Routing::RecognizedRoute#routable? - class NotRoutableEndpointError < Hanami::Routing::Error - # @since 0.5.0 - # @api private - REQUEST_METHOD = "REQUEST_METHOD" - - # @since 0.5.0 - # @api private - PATH_INFO = "PATH_INFO" - - # @since 0.5.0 - def initialize(env) - super %(Cannot find routable endpoint for #{env[REQUEST_METHOD]} "#{env[PATH_INFO]}") - end - end - - # Defines root path - # - # @since 0.7.0 # @api private - # - # @see Hanami::Router#root - ROOT_PATH = "/" + # @since 2.0.0 + attr_reader :url_helpers # Returns the given block as it is. # - # When Hanami::Router is used as a standalone gem and the routes are defined - # into a configuration file, some systems could raise an exception. - # - # Imagine the following file into a Ruby on Rails application: - # - # get '/', to: 'api#index' - # - # Because Ruby on Rails in production mode use to eager load code and the - # routes file uses top level method calls, it crashes the application. - # - # If we wrap these routes with <tt>Hanami::Router.define</tt>, the block - # doesn't get yielded but just returned to the caller as it is. - # - # Usually the receiver of this block is <tt>Hanami::Router#initialize</tt>, - # which finally evaluates the block. - # # @param blk [Proc] a set of route definitions # # @return [Proc] the given block # # @since 0.5.0 # # @example # # apps/web/config/routes.rb # Hanami::Router.define do - # get '/', to: 'home#index' + # get "/", to: ->(*) { ... } # end def self.define(&blk) blk end - # Initialize the router. + # Initialize the router # - # @param options [Hash] the options to initialize the router + # @param base_url [String] the base URL where the HTTP application is + # deployed + # @param prefix [String] the relative URL prefix where the HTTP application + # is deployed + # @param resolver [#call(path, to)] a resolver for route entpoints + # @param block_context [Hanami::Router::Block::Context) + # @param blk [Proc] the route definitions # - # @option options [String] :scheme The HTTP scheme (defaults to `"http"`) - # @option options [String] :host The URL host (defaults to `"localhost"`) - # @option options [String] :port The URL port (defaults to `"80"`) - # @option options [Object, #resolve, #find, #action_separator] :resolver - # the route resolver (defaults to `Hanami::Routing::EndpointResolver.new`) - # @option options [Object, #generate] :route the route class - # (defaults to `Hanami::Routing::Route`) - # @option options [String] :action_separator the separator between controller - # and action name (eg. 'dashboard#show', where '#' is the :action_separator) - # @option options [Object, #pluralize, #singularize] :inflector - # the inflector class (defaults to `Dry::Inflector.new`) - # - # @param blk [Proc] the optional block to define the routes - # - # @return [Hanami::Router] self - # # @since 0.1.0 # - # @example Basic example - # require 'hanami/router' + # @return [Hanami::Router] # - # endpoint = ->(env) { [200, {}, ['Welcome to Hanami::Router!']] } + # @example Base usage + # require "hanami/router" # - # router = Hanami::Router.new - # router.get '/', to: endpoint - # - # # or - # - # router = Hanami::Router.new do - # get '/', to: endpoint + # Hanami::Router.new do + # get "/", to: ->(*) { [200, {}, ["OK"]] } # end + def initialize(base_url: DEFAULT_BASE_URL, prefix: DEFAULT_PREFIX, resolver: DEFAULT_RESOLVER, block_context: nil, &blk) + # TODO: verify if Prefix can handle both name and path prefix + @path_prefix = Prefix.new(prefix) + @name_prefix = Prefix.new("") + @url_helpers = UrlHelpers.new(base_url) + @resolver = resolver + @block_context = block_context + @fixed = {} + @variable = {} + @globbed = {} + @mounted = {} + instance_eval(&blk) + end + + # Resolve the given Rack env to a registered endpoint and invokes it. # - # @example Body parsers + # @param env [Hash] a Rack env # - # require 'hanami/router' - # require 'hanami/middleware/body_parser' + # @return [Array] a finalized Rack env response # - # app = Hanami::Router.new do - # patch '/books/:id', to: ->(env) { [200, {},[env['router.params'].inspect]] } - # end + # @since 0.1.0 + def call(env) + endpoint, params = lookup(env) + + unless endpoint + return not_allowed(env) || + not_found + end + + endpoint.call( + _params(env, params) + ).to_a + end + + # Defines a named root route (a GET route for "/") # - # use Hanami::Middleware::BodyParser, :json - # run app + # @param to [#call] the Rack endpoint + # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # # From the shell + # @since 0.7.0 # - # curl http://localhost:2300/books/1 \ - # -H "Content-Type: application/json" \ - # -H "Accept: application/json" \ - # -d '{"published":"true"}' \ - # -X PATCH + # @see #get + # @see #path + # @see #url # - # # It returns + # @example Proc endpoint + # require "hanami/router" # - # [200, {}, ["{:published=>\"true\",:id=>\"1\"}"]] + # router = Hanami::Router.new do + # root to: ->(env) { [200, {}, ["Hello from Hanami!"]] } + # end # - # @example Custom body parser + # @example Block endpoint + # require "hanami/router" # - # require 'hanami/router' - # require 'hanami/middleware/body_parser' + # router = Hanami::Router.new do + # root do + # "Hello from Hanami!" + # end + # end # + # @example URL helpers + # require "hanami/router" # - # class XmlParser < Hanami::Middleware::BodyParser::Parser - # def mime_types - # ['application/xml', 'text/xml'] - # end - # - # # Parse body and return a Hash - # def parse(body) - # # parse xml - # end - # end - # - # app = Hanami::Router.new do - # patch '/authors/:id', to: ->(env) { [200, {},[env['router.params'].inspect]] } + # router = Hanami::Router.new(base_url: "https://hanamirb.org") do + # root do + # "Hello from Hanami!" + # end # end # - # use Hanami::Middleware::BodyParser, XmlParser - # run app - # - # # From the shell - # - # curl http://localhost:2300/authors/1 \ - # -H "Content-Type: application/xml" \ - # -H "Accept: application/xml" \ - # -d '<name>LG</name>' \ - # -X PATCH - # - # # It returns - # - # [200, {}, ["{:name=>\"LG\",:id=>\"1\"}"]] - # - # rubocop:disable Metrics/MethodLength - def initialize(scheme: "http", host: "localhost", port: 80, prefix: "", namespace: nil, configuration: nil, inflector: Dry::Inflector.new, not_found: NOT_FOUND, not_allowed: NOT_ALLOWED, &blk) - @routes = [] - @named = {} - @namespace = namespace - @configuration = configuration - @base = Routing::Uri.build(scheme: scheme, host: host, port: port) - @prefix = Utils::PathPrefix.new(prefix) - @inflector = inflector - @not_found = not_found - @not_allowed = not_allowed - instance_eval(&blk) unless blk.nil? - freeze + # router.path(:root) # => "/" + # router.url(:root) # => "https://hanamirb.org" + def root(to: nil, &blk) + get("/", to: to, as: :root, &blk) end - # rubocop:enable Metrics/MethodLength - # Freeze the router + # Defines a route that accepts GET requests for the given path. + # It also defines a route to accept HEAD requests. # - # @since 2.0.0 - def freeze - @routes.freeze - super - end - - # Returns self - # - # This is a duck-typing trick for compatibility with `Hanami::Application`. - # It's used by `Hanami::Routing::RoutesInspector` to inspect both apps and - # routers. - # - # @return [self] - # - # @since 0.2.0 - # @api private - def routes - self - end - - # Check if there are defined routes - # - # @return [TrueClass,FalseClass] the result of the check - # - # @since 0.2.0 - # @api private - # - # @example - # - # router = Hanami::Router.new - # router.defined? # => false - # - # router = Hanami::Router.new { get '/', to: ->(env) { } } - # router.defined? # => true - def defined? - @routes.any? - end - - # Defines a route that accepts a GET request for the given path. - # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # # @since 0.1.0 # - # @example Fixed matching string - # require 'hanami/router' + # @see #initialize + # @see #path + # @see #url # - # router = Hanami::Router.new - # router.get '/hanami', to: ->(env) { [200, {}, ['Hello from Hanami!']] } + # @example Proc endpoint + # require "hanami/router" # - # @example String matching with variables - # require 'hanami/router' + # Hanami::Router.new do + # get "/", to: ->(*) { [200, {}, ["OK"]] } + # end # - # router = Hanami::Router.new - # router.get '/flowers/:id', - # to: ->(env) { - # [ - # 200, - # {}, - # ["Hello from Flower no. #{ env['router.params'][:id] }!"] - # ] - # } + # @example Block endpoint + # require "hanami/router" # - # @example Variables Constraints - # require 'hanami/router' - # - # router = Hanami::Router.new - # router.get '/flowers/:id', - # id: /\d+/, - # to: ->(env) { [200, {}, [":id must be a number!"]] } - # - # @example String matching with globbling - # require 'hanami/router' - # - # router = Hanami::Router.new - # router.get '/*', - # to: ->(env) { - # [ - # 200, - # {}, - # ["This is catch all: #{ env['router.params'].inspect }!"] - # ] - # } - # - # @example String matching with optional tokens - # require 'hanami/router' - # - # router = Hanami::Router.new - # router.get '/hanami(.:format)', - # to: ->(env) { - # [200, {}, ["You've requested #{ env['router.params'][:format] }!"]] - # } - # - # @example Named routes - # require 'hanami/router' - # - # router = Hanami::Router.new(scheme: 'https', host: 'hanamirb.org') - # router.get '/hanami', - # to: ->(env) { [200, {}, ['Hello from Hanami!']] }, - # as: :hanami - # - # router.path(:hanami) # => "/hanami" - # router.url(:hanami) # => "https://hanamirb.org/hanami" - # - # @example Duck typed endpoints (Rack compatible objects) - # require 'hanami/router' - # - # router = Hanami::Router.new - # - # router.get '/hanami', to: ->(env) { [200, {}, ['Hello from Hanami!']] } - # router.get '/middleware', to: Middleware - # router.get '/rack-app', to: RackApp.new - # router.get '/method', to: ActionControllerSubclass.action(:new) - # - # # Everything that responds to #call is invoked as it is - # - # @example Duck typed endpoints (strings) - # require 'hanami/router' - # - # class RackApp - # def call(env) - # # ... + # Hanami::Router.new do + # get "/" do + # "OK" # end # end # - # router = Hanami::Router.new - # router.get '/hanami', to: 'rack_app' # it will map to RackApp.new + # @example Named route + # require "hanami/router" # - # @example Duck typed endpoints (string: controller + action) - # require 'hanami/router' - # - # module Flowers - # class Index - # def call(env) - # # ... - # end - # end + # router = Hanami::Router.new do + # get "/", to: ->(*) { [200, {}, ["OK"]] }, as: :welcome # end # - # router = Hanami::Router.new - # router.get '/flowers', to: 'flowers#index' + # router.path(:welcome) # => "/" + # router.url(:welcome) # => "http://localhost/" # - # # It will map to Flowers::Index.new, which is the - # # Hanami::Controller convention. - def get(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(GET, path, to, as, namespace, configuration, constraints, &blk) + # @example Constraints + # require "hanami/router" + # + # Hanami::Router.new do + # get "/users/:id", to: ->(*) { [200, {}, ["OK"]] }, id: /\d+/ + # end + def get(path, to: nil, as: nil, **constraints, &blk) + add_route("GET", path, to, as, constraints, &blk) + add_route("HEAD", path, to, as, constraints, &blk) end - # Defines a route that accepts a POST request for the given path. + # Defines a route that accepts POST requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @see Hanami::Router#get - # # @since 0.1.0 - def post(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(POST, path, to, as, namespace, configuration, constraints, &blk) + # + # @see #get + # @see #initialize + # @see #path + # @see #url + def post(path, to: nil, as: nil, **constraints, &blk) + add_route("POST", path, to, as, constraints, &blk) end - # Defines a route that accepts a PUT request for the given path. + # Defines a route that accepts PATCH requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @see Hanami::Router#get - # # @since 0.1.0 - def put(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(PUT, path, to, as, namespace, configuration, constraints, &blk) + # + # @see #get + # @see #initialize + # @see #path + # @see #url + def patch(path, to: nil, as: nil, **constraints, &blk) + add_route("PATCH", path, to, as, constraints, &blk) end - # Defines a route that accepts a PATCH request for the given path. + # Defines a route that accepts PUT requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @see Hanami::Router#get - # # @since 0.1.0 - def patch(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(PATCH, path, to, as, namespace, configuration, constraints, &blk) + # + # @see #get + # @see #initialize + # @see #path + # @see #url + def put(path, to: nil, as: nil, **constraints, &blk) + add_route("PUT", path, to, as, constraints, &blk) end - # Defines a route that accepts a DELETE request for the given path. + # Defines a route that accepts DELETE requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @see Hanami::Router#get - # # @since 0.1.0 - def delete(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(DELETE, path, to, as, namespace, configuration, constraints, &blk) + # + # @see #get + # @see #initialize + # @see #path + # @see #url + def delete(path, to: nil, as: nil, **constraints, &blk) + add_route("DELETE", path, to, as, constraints, &blk) end - # Defines a route that accepts a TRACE request for the given path. + # Defines a route that accepts TRACE requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @see Hanami::Router#get - # # @since 0.1.0 - def trace(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(TRACE, path, to, as, namespace, configuration, constraints, &blk) + # + # @see #get + # @see #initialize + # @see #path + # @see #url + def trace(path, to: nil, as: nil, **constraints, &blk) + add_route("TRACE", path, to, as, constraints, &blk) end - # Defines a route that accepts a LINK request for the given path. + # Defines a route that accepts OPTIONS requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor + # @since 0.1.0 # - # @see Hanami::Router#get - # - # @since 0.8.0 - def link(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(LINK, path, to, as, namespace, configuration, constraints, &blk) + # @see #get + # @see #initialize + # @see #path + # @see #url + def options(path, to: nil, as: nil, **constraints, &blk) + add_route("OPTIONS", path, to, as, constraints, &blk) end - # Defines a route that accepts an UNLINK request for the given path. + # Defines a route that accepts LINK requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor + # @since 0.1.0 # - # @see Hanami::Router#get - # - # @since 0.8.0 - def unlink(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(UNLINK, path, to, as, namespace, configuration, constraints, &blk) + # @see #get + # @see #initialize + # @see #path + # @see #url + def link(path, to: nil, as: nil, **constraints, &blk) + add_route("LINK", path, to, as, constraints, &blk) end - # Defines a route that accepts a OPTIONS request for the given path. + # Defines a route that accepts UNLINK requests for the given path. # # @param path [String] the relative URL to be matched - # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param constraints [Hash] a set of constraints for path variables # @param blk [Proc] the anonymous proc to be used as endpoint for the route # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @see Hanami::Router#get - # # @since 0.1.0 - def options(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) - add_route(OPTIONS, path, to, as, namespace, configuration, constraints, &blk) - end - - # Defines a root route (a GET route for '/') # - # @param options [Hash] the options to customize the route - # @option options [String,Proc,Class,Object#call] :to the endpoint - # - # @param blk [Proc] the anonymous proc to be used as endpoint for the route - # - # @return [Hanami::Routing::Route] this may vary according to the :route - # option passed to the constructor - # - # @since 0.7.0 - # - # @example Fixed matching string - # require 'hanami/router' - # - # router = Hanami::Router.new - # router.root to: ->(env) { [200, {}, ['Hello from Hanami!']] } - # - # @example Included names as `root` (for path and url helpers) - # require 'hanami/router' - # - # router = Hanami::Router.new(scheme: 'https', host: 'hanamirb.org') - # router.root to: ->(env) { [200, {}, ['Hello from Hanami!']] } - # - # router.path(:root) # => "/" - # router.url(:root) # => "https://hanamirb.org/" - def root(to: nil, as: :root, prefix: Utils::PathPrefix.new, namespace: nil, configuration: nil, &blk) - add_route(GET, prefix.join(ROOT_PATH), to, as, namespace, configuration, &blk) + # @see #get + # @see #initialize + # @see #path + # @see #url + def unlink(path, to: nil, as: nil, **constraints, &blk) + add_route("UNLINK", path, to, as, constraints, &blk) end - # Defines an HTTP redirect + # Defines a route that redirects the incoming request to another path. # - # @param path [String] the path that needs to be redirected - # @param options [Hash] the options to customize the redirect behavior - # @option options [Fixnum] the HTTP status to return (defaults to `301`) + # @param path [String] the relative URL to be matched + # @param to [#call] the Rack endpoint + # @param as [Symbol] a unique name for the route + # @param code [Integer] a HTTP status code to use for the redirect # - # @return [Hanami::Routing::Route] the generated route. - # This may vary according to the `:route` option passed to the initializer - # # @since 0.1.0 # - # @see Hanami::Router - # - # @example - # require 'hanami/router' - # - # Hanami::Router.new do - # redirect '/legacy', to: '/new_endpoint' - # redirect '/legacy2', to: '/new_endpoint2', code: 302 - # end - # - # @example - # require 'hanami/router' - # - # router = Hanami::Router.new - # router.redirect '/legacy', to: '/new_endpoint' - def redirect(path, to:, code: 301) - to = Routing::Redirect.new(@prefix.join(to).to_s, code) - add_route(GET, path, to) + # @see #get + # @see #initialize + def redirect(path, to: nil, as: nil, code: DEFAULT_REDIRECT_CODE) + get(path, to: _redirect(to, code), as: as) end - # Defines a Ruby block: all the routes defined within it will be prefixed - # with the given relative path. + # Defines a routing scope. Routes defined in the context of a scope, + # inherit the given path as path prefix and as a named routes prefix. # - # Prefix blocks can be nested multiple times. + # @param path [String] the scope path to be used as a path prefix + # @param blk [Proc] the routes definitions withing the scope # - # @param path [String] the relative path where the nested routes will - # be mounted - # @param blk [Proc] the block that defines the resources - # - # @return [void] - # # @since 2.0.0 # - # @see Hanami::Router + # @see #path # - # @example Basic example + # @example # require "hanami/router" # - # Hanami::Router.new do - # prefix "trees" do - # get "/sequoia", to: endpoint # => "/trees/sequoia" + # router = Hanami::Router.new do + # scope "v1" do + # get "/users", to: ->(*) { ... }, as: :users # end # end # - # @example Nested prefix - # require "hanami/router" - # - # Hanami::Router.new do - # prefix "animals" do - # prefix "mammals" do - # get "/cats", to: endpoint # => "/animals/mammals/cats" - # end - # end - # end - def prefix(path, namespace: nil, configuration: nil, &blk) - Routing::Prefix.new(self, path, namespace, configuration, &blk) + # router.path(:v1_users) # => "/v1/users" + def scope(path, &blk) + path_prefix = @path_prefix + name_prefix = @name_prefix + + begin + @path_prefix = @path_prefix.join(path.to_s) + @name_prefix = @name_prefix.join(path.to_s) + instance_eval(&blk) + ensure + @path_prefix = path_prefix + @name_prefix = name_prefix + end end - # Defines a scope for routes. + # Mount a Rack application at the specified path. + # All the requests starting with the specified path, will be forwarded to + # the given application. # - # A scope is a combination of a path prefix and a Ruby namespace. + # All the other methods (eg `#get`) support callable objects, but they + # restrict the range of the acceptable HTTP verb. Mounting an application + # with #mount doesn't apply this kind of restriction at the router level, + # but let the application to decide. # - # @param prefix [String] the path prefix - # @param namespace [Module] the Ruby namespace where to lookup endpoints - # @param configuration [Hanami::Controller::Configuration] the action - # configuration - # @param blk [Proc] the routes definition block + # @param app [#call] a class or an object that responds to #call + # @param at [String] the relative path where to mount the app + # @param constraints [Hash] a set of constraints for path variables # - # @since 2.0.0 - # @api private + # @since 0.1.1 # # @example # require "hanami/router" - # require "hanami/controller" # - # configuration = Hanami::Controller::Configuration.new - # # Hanami::Router.new do - # scope "/admin", namespace: Admin::Controllers, configuration: configuration do - # root to: "home#index" - # end + # mount MyRackApp.new, at: "/foo" # end - def scope(prefix, namespace:, configuration:, &blk) - Routing::Scope.new(self, prefix, namespace, configuration, &blk) + def mount(app, at:, **constraints) + path = prefixed_path(at) + prefix = Segment.fabricate(path, **constraints) + @mounted[prefix] = @resolver.call(path, app) end - # Defines a set of named routes for a single RESTful resource. - # It has a built-in integration for Hanami::Controller. + # Generate an relative URL for a specified named route. + # The additional arguments will be used to compose the relative URL - in + # case it has tokens to match - and for compose the query string. # - # @param name [String] the name of the resource - # @param options [Hash] a set of options to customize the routes - # @option options [Array<Symbol>] :only a subset of the default routes - # that we want to generate - # @option options [Array<Symbol>] :except prevent the given routes to be - # generated - # @param blk [Proc] a block of code to generate additional routes + # @param name [Symbol] the route name # - # @return [Hanami::Routing::Resource] + # @return [String] # - # @since 0.1.0 + # @raise [Hanami::Routing::InvalidRouteException] when the router fails to + # recognize a route, because of the given arguments. # - # @see Hanami::Routing::Resource - # @see Hanami::Routing::Resource::Action - # @see Hanami::Routing::Resource::Options - # - # @example Default usage - # require 'hanami/router' - # - # Hanami::Router.new do - # resource 'identity' - # end - # - # # It generates: - # # - # # +--------+----------------+-------------------+----------+----------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+----------------+-------------------+----------+----------------+ - # # | GET | /identity | Identity::Show | :show | :identity | - # # | GET | /identity/new | Identity::New | :new | :new_identity | - # # | POST | /identity | Identity::Create | :create | :identity | - # # | GET | /identity/edit | Identity::Edit | :edit | :edit_identity | - # # | PATCH | /identity | Identity::Update | :update | :identity | - # # | DELETE | /identity | Identity::Destroy | :destroy | :identity | - # # +--------+----------------+-------------------+----------+----------------+ - # - # - # - # @example Limit the generated routes with :only - # require 'hanami/router' - # - # Hanami::Router.new do - # resource 'identity', only: [:show, :new, :create] - # end - # - # # It generates: - # # - # # +--------+----------------+------------------+----------+----------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+----------------+------------------+----------+----------------+ - # # | GET | /identity | Identity::Show | :show | :identity | - # # | GET | /identity/new | Identity::New | :new | :new_identity | - # # | POST | /identity | Identity::Create | :create | :identity | - # # +--------+----------------+------------------+----------+----------------+ - # - # - # - # @example Limit the generated routes with :except - # require 'hanami/router' - # - # Hanami::Router.new do - # resource 'identity', except: [:edit, :update, :destroy] - # end - # - # # It generates: - # # - # # +--------+----------------+------------------+----------+----------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+----------------+------------------+----------+----------------+ - # # | GET | /identity | Identity::Show | :show | :identity | - # # | GET | /identity/new | Identity::New | :new | :new_identity | - # # | POST | /identity | Identity::Create | :create | :identity | - # # +--------+----------------+------------------+----------+----------------+ - # - # - # - # @example Additional single routes - # require 'hanami/router' - # - # Hanami::Router.new do - # resource 'identity', only: [] do - # member do - # patch 'activate' - # end - # end - # end - # - # # It generates: - # # - # # +--------+--------------------+--------------------+------+--------------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+--------------------+--------------------+------+--------------------+ - # # | PATCH | /identity/activate | Identity::Activate | | :activate_identity | - # # +--------+--------------------+--------------------+------+--------------------+ - # - # - # - # @example Additional collection routes - # require 'hanami/router' - # - # Hanami::Router.new do - # resource 'identity', only: [] do - # collection do - # get 'keys' - # end - # end - # end - # - # # It generates: - # # - # # +------+----------------+----------------+------+----------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +------+----------------+----------------+------+----------------+ - # # | GET | /identity/keys | Identity::Keys | | :keys_identity | - # # +------+----------------+----------------+------+----------------+ - def resource(name, options = {}, &blk) - Routing::Resource.new(self, name, options.merge(separator: Routing::Endpoint::ACTION_SEPARATOR), &blk) - end - - # Defines a set of named routes for a plural RESTful resource. - # It has a built-in integration for Hanami::Controller. - # - # @param name [String] the name of the resource - # @param options [Hash] a set of options to customize the routes - # @option options [Array<Symbol>] :only a subset of the default routes - # that we want to generate - # @option options [Array<Symbol>] :except prevent the given routes to be - # generated - # @param blk [Proc] a block of code to generate additional routes - # - # @return [Hanami::Routing::Resources] - # # @since 0.1.0 # - # @see Hanami::Routing::Resources - # @see Hanami::Routing::Resources::Action - # @see Hanami::Routing::Resource::Options + # @see #url # - # @example Default usage - # require 'hanami/router' + # @example + # require "hanami/router" # - # Hanami::Router.new do - # resources 'articles' + # router = Hanami::Router.new(base_url: "https://hanamirb.org") do + # get "/login", to: ->(*) { ... }, as: :login + # get "/:name", to: ->(*) { ... }, as: :framework # end # - # # It generates: - # # - # # +--------+--------------------+-------------------+----------+----------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+--------------------+-------------------+----------+----------------+ - # # | GET | /articles | Articles::Index | :index | :articles | - # # | GET | /articles/:id | Articles::Show | :show | :articles | - # # | GET | /articles/new | Articles::New | :new | :new_articles | - # # | POST | /articles | Articles::Create | :create | :articles | - # # | GET | /articles/:id/edit | Articles::Edit | :edit | :edit_articles | - # # | PATCH | /articles/:id | Articles::Update | :update | :articles | - # # | DELETE | /articles/:id | Articles::Destroy | :destroy | :articles | - # # +--------+--------------------+-------------------+----------+----------------+ - # - # - # - # @example Limit the generated routes with :only - # require 'hanami/router' - # - # Hanami::Router.new do - # resources 'articles', only: [:index] - # end - # - # # It generates: - # # - # # +------+-----------+-----------------+--------+-------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +------+-----------+-----------------+--------+-------------+ - # # | GET | /articles | Articles::Index | :index | :articles | - # # +------+-----------+-----------------+--------+-------------+ - # - # - # - # @example Limit the generated routes with :except - # require 'hanami/router' - # - # Hanami::Router.new do - # resources 'articles', except: [:edit, :update] - # end - # - # # It generates: - # # - # # +--------+--------------------+-------------------+----------+----------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+--------------------+-------------------+----------+----------------+ - # # | GET | /articles | Articles::Index | :index | :articles | - # # | GET | /articles/:id | Articles::Show | :show | :articles | - # # | GET | /articles/new | Articles::New | :new | :new_articles | - # # | POST | /articles | Articles::Create | :create | :articles | - # # | DELETE | /articles/:id | Articles::Destroy | :destroy | :articles | - # # +--------+--------------------+-------------------+----------+----------------+ - # - # - # - # @example Additional single routes - # require 'hanami/router' - # - # Hanami::Router.new do - # resources 'articles', only: [] do - # member do - # patch 'publish' - # end - # end - # end - # - # # It generates: - # # - # # +--------+-----------------------+-------------------+------+-------------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +--------+-----------------------+-------------------+------+-------------------+ - # # | PATCH | /articles/:id/publish | Articles::Publish | | :publish_articles | - # # +--------+-----------------------+-------------------+------+-------------------+ - # - # - # - # @example Additional collection routes - # require 'hanami/router' - # - # Hanami::Router.new do - # resources 'articles', only: [] do - # collection do - # get 'search' - # end - # end - # end - # - # # It generates: - # # - # # +------+------------------+------------------+------+------------------+ - # # | Verb | Path | Action | Name | Named Route | - # # +------+------------------+------------------+------+------------------+ - # # | GET | /articles/search | Articles::Search | | :search_articles | - # # +------+------------------+------------------+------+------------------+ - def resources(name, options = {}, &blk) - Routing::Resources.new(self, name, options.merge(separator: Routing::Endpoint::ACTION_SEPARATOR), &blk) + # router.path(:login) # => "/login" + # router.path(:login, return_to: "/dashboard") # => "/login?return_to=%2Fdashboard" + # router.path(:framework, name: "router") # => "/router" + def path(name, variables = {}) + @url_helpers.path(name, variables) end - # Mount a Rack application at the specified path. - # All the requests starting with the specified path, will be forwarded to - # the given application. + # Generate an absolute URL for a specified named route. + # The additional arguments will be used to compose the relative URL - in + # case it has tokens to match - and for compose the query string. # - # All the other methods (eg #get) support callable objects, but they - # restrict the range of the acceptable HTTP verb. Mounting an application - # with #mount doesn't apply this kind of restriction at the router level, - # but let the application to decide. + # @param name [Symbol] the route name # - # @param app [#call] a class or an object that responds to #call - # @param options [Hash] the options to customize the mount - # @option options [:at] the relative path where to mount the app + # @return [String] # - # @since 0.1.1 + # @raise [Hanami::Routing::InvalidRouteException] when the router fails to + # recognize a route, because of the given arguments. # - # @example Basic usage - # require 'hanami/router' + # @since 0.1.0 # - # Hanami::Router.new do - # mount Api::App.new, at: '/api' - # end + # @see #path # - # # Requests: - # # - # # GET /api # => 200 - # # GET /api/articles # => 200 - # # POST /api/articles # => 200 - # # GET /api/unknown # => 404 + # @example + # require "hanami/router" # - # @example Difference between #get and #mount - # require 'hanami/router' - # - # Hanami::Router.new do - # get '/rack1', to: RackOne.new - # mount RackTwo.new, at: '/rack2' + # router = Hanami::Router.new(base_url: "https://hanamirb.org") do + # get "/login", to: ->(*) { ... }, as: :login + # get "/:name", to: ->(*) { ... }, as: :framework # end # - # # Requests: - # # - # # # /rack1 will only accept GET - # # GET /rack1 # => 200 (RackOne.new) - # # POST /rack1 # => 405 - # # - # # # /rack2 accepts all the verbs and delegate the decision to RackTwo - # # GET /rack2 # => 200 (RackTwo.new) - # # POST /rack2 # => 200 (RackTwo.new) - # - # @example Types of mountable applications - # require 'hanami/router' - # - # class RackOne - # def self.call(env) - # end - # end - # - # class RackTwo - # def call(env) - # end - # end - # - # class RackThree - # def call(env) - # end - # end - # - # module Dashboard - # class Index - # def call(env) - # end - # end - # end - # - # Hanami::Router.new do - # mount RackOne, at: '/rack1' - # mount RackTwo, at: '/rack2' - # mount RackThree.new, at: '/rack3' - # mount ->(env) {[200, {}, ['Rack Four']]}, at: '/rack4' - # mount 'dashboard#index', at: '/dashboard' - # end - # - # # 1. RackOne is used as it is (class), because it respond to .call - # # 2. RackTwo is initialized, because it respond to #call - # # 3. RackThree is used as it is (object), because it respond to #call - # # 4. That Proc is used as it is, because it respond to #call - # # 5. That string is resolved as Dashboard::Index (Hanami::Controller) - def mount(app, at:, host: nil) - app = App.new(@prefix.join(at).to_s, Routing::Endpoint.find(app, @namespace), host: host) - @routes.push(app) + # router.url(:login) # => "https://hanamirb.org/login" + # router.url(:login, return_to: "/dashboard") # => "https://hanamirb.org/login?return_to=%2Fdashboard" + # router.url(:framework, name: "router") # => "https://hanamirb.org/router" + def url(name, variables = {}) + @url_helpers.url(name, variables) end - # Resolve the given Rack env to a registered endpoint and invoke it. - # - # @param env [Hash] a Rack env instance - # - # @return [Rack::Response, Array] - # - # @since 0.1.0 - def call(env) - (@routes.find { |r| r.match?(env) } || fallback(env)).call(env) - end - - def fallback(env) - if @routes.find { |r| r.match_path?(env) } - @not_allowed - else - @not_found - end - end - # Recognize the given env, path, or name and return a route for testing # inspection. # # If the route cannot be recognized, it still returns an object for testing # inspection. @@ -1065,190 +484,164 @@ # # @see Hanami::Router#env_for # @see Hanami::Routing::RecognizedRoute # # @example Successful Path Recognition - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # - # route = router.recognize('/books/23') + # route = router.recognize("/books/23") # route.verb # => "GET" (default) # route.routable? # => true # route.params # => {:id=>"23"} # # @example Successful Rack Env Recognition - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # - # route = router.recognize(Rack::MockRequest.env_for('/books/23')) + # route = router.recognize(Rack::MockRequest.env_for("/books/23")) # route.verb # => "GET" (default) # route.routable? # => true # route.params # => {:id=>"23"} # # @example Successful Named Route Recognition - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # # route = router.recognize(:book, id: 23) # route.verb # => "GET" (default) # route.routable? # => true # route.params # => {:id=>"23"} # # @example Failing Recognition For Unknown Path - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # - # route = router.recognize('/books') + # route = router.recognize("/books") # route.verb # => "GET" (default) # route.routable? # => false # # @example Failing Recognition For Path With Wrong HTTP Verb - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # - # route = router.recognize('/books/23', method: :post) + # route = router.recognize("/books/23", method: :post) # route.verb # => "POST" # route.routable? # => false # # @example Failing Recognition For Rack Env With Wrong HTTP Verb - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # - # route = router.recognize(Rack::MockRequest.env_for('/books/23', method: :post)) + # route = router.recognize(Rack::MockRequest.env_for("/books/23", method: :post)) # route.verb # => "POST" # route.routable? # => false # # @example Failing Recognition Named Route With Wrong Params - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # # route = router.recognize(:book) # route.verb # => "GET" (default) # route.routable? # => false # # @example Failing Recognition Named Route With Wrong HTTP Verb - # require 'hanami/router' + # require "hanami/router" # # router = Hanami::Router.new do - # get '/books/:id', to: 'books#show', as: :book + # get "/books/:id", to: ->(*) { ... }, as: :book # end # # route = router.recognize(:book, {method: :post}, {id: 1}) # route.verb # => "POST" # route.routable? # => false # route.params # => {:id=>"1"} - def recognize(env, options = {}, params = nil) - env = env_for(env, options, params) - # FIXME: this finder is shared with #call and should be extracted - route = @routes.find { |r| r.match?(env) } + def recognize(env, params = {}, options = {}) + require "hanami/router/recognized_route" + env = env_for(env, params, options) + endpoint, params = lookup(env) - Routing::RecognizedRoute.new(route, env, @namespace) + RecognizedRoute.new( + endpoint, _params(env, params) + ) end - # Generate an relative URL for a specified named route. - # The additional arguments will be used to compose the relative URL - in - # case it has tokens to match - and for compose the query string. - # - # @param route [Symbol] the route name - # - # @return [String] - # - # @raise [Hanami::Routing::InvalidRouteException] when the router fails to - # recognize a route, because of the given arguments. - # - # @since 0.1.0 - # - # @example - # require 'hanami/router' - # - # router = Hanami::Router.new(scheme: 'https', host: 'hanamirb.org') - # router.get '/login', to: 'sessions#new', as: :login - # router.get '/:name', to: 'frameworks#show', as: :framework - # - # router.path(:login) # => "/login" - # router.path(:login, return_to: '/dashboard') # => "/login?return_to=%2Fdashboard" - # router.path(:framework, name: 'router') # => "/router" - def path(route, args = {}) - @named.fetch(route).path(args) - rescue KeyError - raise Hanami::Routing::InvalidRouteException.new("No route could be generated for #{route.inspect} - please check given arguments") + # @since 2.0.0 + # @api private + def fixed(env) + @fixed.dig(env["REQUEST_METHOD"], env["PATH_INFO"]) end - # Generate a URL for a specified named route. - # The additional arguments will be used to compose the relative URL - in - # case it has tokens to match - and for compose the query string. - # - # @param route [Symbol] the route name - # - # @return [String] - # - # @raise [Hanami::Routing::InvalidRouteException] when the router fails to - # recognize a route, because of the given arguments. - # - # @since 0.1.0 - # - # @example - # require 'hanami/router' - # - # router = Hanami::Router.new(scheme: 'https', host: 'hanamirb.org') - # router.get '/login', to: 'sessions#new', as: :login - # router.get '/:name', to: 'frameworks#show', as: :framework - # - # router.url(:login) # => "https://hanamirb.org/login" - # router.url(:login, return_to: '/dashboard') # => "https://hanamirb.org/login?return_to=%2Fdashboard" - # router.url(:framework, name: 'router') # => "https://hanamirb.org/router" - def url(route, args = {}) - @base + path(route, args) + # @since 2.0.0 + # @api private + def variable(env) + @variable[env["REQUEST_METHOD"]]&.find(env["PATH_INFO"]) end - # Returns an routes inspector - # - # @since 0.2.0 - # - # @see Hanami::Routing::RoutesInspector - # - # @example - # require 'hanami/router' - # - # router = Hanami::Router.new do - # get '/', to: 'home#index' - # get '/login', to: 'sessions#new', as: :login - # post '/login', to: 'sessions#create' - # delete '/logout', to: 'sessions#destroy', as: :logout - # end - # - # puts router.inspector - # # => GET, HEAD / Home::Index - # login GET, HEAD /login Sessions::New - # POST /login Sessions::Create - # logout GET, HEAD /logout Sessions::Destroy - def inspector - require "hanami/routing/routes_inspector" - Routing::RoutesInspector.new(@routes, @prefix) + # @since 2.0.0 + # @api private + def globbed(env) + @globbed[env["REQUEST_METHOD"]]&.each do |path, to| + if (match = path.match(env["PATH_INFO"])) + return [to, match.named_captures] + end + end + + nil end + # @since 2.0.0 + # @api private + def mounted(env) + @mounted.each do |prefix, app| + next unless (match = prefix.peek_match(env["PATH_INFO"])) + + # TODO: ensure compatibility with existing env["SCRIPT_NAME"] + # TODO: cleanup this code + env["SCRIPT_NAME"] = env["SCRIPT_NAME"].to_s + prefix.to_s + env["PATH_INFO"] = env["PATH_INFO"].sub(prefix.to_s, "") + env["PATH_INFO"] = "/" if env["PATH_INFO"] == "" + + return [app, match.named_captures] + end + + nil + end + + # @since 2.0.0 + # @api private + def not_allowed(env) + (_not_allowed_fixed(env) || _not_allowed_variable(env)) and return NOT_ALLOWED + end + + # @since 2.0.0 + # @api private + def not_found + NOT_FOUND + end + protected # Fabricate Rack env for the given Rack env, path or named route # # @param env [Hash, String, Symbol] Rack env, path or route name @@ -1260,106 +653,198 @@ # @since 0.5.0 # @api private # # @see Hanami::Router#recognize # @see http://www.rubydoc.info/github/rack/rack/Rack%2FMockRequest.env_for - def env_for(env, options = {}, params = nil) # rubocop:disable Metrics/MethodLength + def env_for(env, params = {}, options = {}) # rubocop:disable Metrics/MethodLength + require "rack/mock" + case env - when String - Rack::MockRequest.env_for(env, options) - when Symbol + when ::String + ::Rack::MockRequest.env_for(env, options) + when ::Symbol begin - url = path(env, params || options) - return env_for(url, options) - rescue Hanami::Routing::InvalidRouteException - {} + url = path(env, params) + return env_for(url, params, options) # rubocop:disable Style/RedundantReturn + rescue Hanami::Router::InvalidRouteException + EMPTY_RACK_ENV.dup end else env end end private - PATH_INFO = "PATH_INFO" - SCRIPT_NAME = "SCRIPT_NAME" - SERVER_NAME = "SERVER_NAME" - REQUEST_METHOD = "REQUEST_METHOD" + # @since 2.0.0 + # @api private + DEFAULT_BASE_URL = "http://localhost" + # @since 2.0.0 + # @api private + DEFAULT_PREFIX = "/" + + # @since 2.0.0 + # @api private + DEFAULT_RESOLVER = ->(_, to) { to } + + # @since 2.0.0 + # @api private + DEFAULT_REDIRECT_CODE = 301 + + # @since 2.0.0 + # @api private + NOT_FOUND = [404, { "Content-Length" => "9" }, ["Not Found"]].freeze + + # @since 2.0.0 + # @api private + NOT_ALLOWED = [405, { "Content-Length" => "11" }, ["Not Allowed"]].freeze + + # @since 2.0.0 + # @api private PARAMS = "router.params" - GET = "GET" - HEAD = "HEAD" - POST = "POST" - PUT = "PUT" - PATCH = "PATCH" - DELETE = "DELETE" - TRACE = "TRACE" - OPTIONS = "OPTIONS" - LINK = "LINK" - UNLINK = "UNLINK" + # @since 2.0.0 + # @api private + EMPTY_PARAMS = {}.freeze - NOT_FOUND = ->(_) { [404, { "Content-Length" => "9" }, ["Not Found"]] }.freeze - NOT_ALLOWED = ->(_) { [405, { "Content-Length" => "18" }, ["Method Not Allowed"]] }.freeze - ROOT = "/" + # @since 2.0.0 + # @api private + EMPTY_RACK_ENV = {}.freeze - BODY = 2 + # @since 2.0.0 + # @api private + def lookup(env) + endpoint = fixed(env) + return [endpoint, EMPTY_PARAMS] if endpoint - attr_reader :configuration + variable(env) || globbed(env) || mounted(env) + end - # Application - # # @since 2.0.0 # @api private - class App - def initialize(path, endpoint, host: nil) - @path = Mustermann.new(path, type: :rails, version: "5.0") - @prefix = path.to_s - @endpoint = endpoint - @host = host - freeze - end + def add_route(http_method, path, to, as, constraints, &blk) + path = prefixed_path(path) + to = resolve_endpoint(path, to, blk) - def match?(env) - match_path?(env) + if globbed?(path) + add_globbed_route(http_method, path, to, constraints) + elsif variable?(path) + add_variable_route(http_method, path, to, constraints) + else + add_fixed_route(http_method, path, to) end - def match_path?(env) - result = env[PATH_INFO].start_with?(@prefix) - result &&= @host == env[SERVER_NAME] unless @host.nil? + add_named_route(path, as, constraints) if as + end - result - end + # @since 2.0.0 + # @api private + def resolve_endpoint(path, to, blk) + (to || blk) or raise MissingEndpointError.new(path) + to = Block.new(@block_context, blk) if to.nil? - def call(env) - env[PARAMS] ||= {} - env[PARAMS].merge!(Utils::Hash.deep_symbolize(@path.params(env[PATH_INFO]) || {})) + @resolver.call(path, to) + end - env[SCRIPT_NAME] = @prefix - env[PATH_INFO] = env[PATH_INFO].sub(@prefix, "") - env[PATH_INFO] = "/" if env[PATH_INFO] == "" + # @since 2.0.0 + # @api private + def add_globbed_route(http_method, path, to, constraints) + @globbed[http_method] ||= [] + @globbed[http_method] << [Segment.fabricate(path, **constraints), to] + end - @endpoint.call(env) + # @since 2.0.0 + # @api private + def add_variable_route(http_method, path, to, constraints) + @variable[http_method] ||= Trie.new + @variable[http_method].add(path, to, constraints) + end + + # @since 2.0.0 + # @api private + def add_fixed_route(http_method, path, to) + @fixed[http_method] ||= {} + @fixed[http_method][path] = to + end + + # @since 2.0.0 + # @api private + def add_named_route(path, as, constraints) + @url_helpers.add(prefixed_name(as), Segment.fabricate(path, **constraints)) + end + + # @since 2.0.0 + # @api private + def variable?(path) + /:/.match?(path) + end + + # @since 2.0.0 + # @api private + def globbed?(path) + /\*/.match?(path) + end + + # @since 2.0.0 + # @api private + def prefixed_path(path) + @path_prefix.join(path).to_s + end + + # @since 2.0.0 + # @api private + def prefixed_name(name) + @name_prefix.relative_join(name, "_").to_sym + end + + # @since 2.0.0 + # @api private + def _redirect(to, code) + body = Rack::Utils::HTTP_STATUS_CODES.fetch(code) do + raise UnknownHTTPStatusCodeError.new(code) end + + destination = prefixed_path(to) + Redirect.new(destination, ->(*) { [code, { "Location" => destination }, [body]] }) end - def add_route(verb, path, to, as = nil, namespace = nil, config = nil, constraints = {}, &blk) - to ||= blk - config ||= configuration + # @since 2.0.0 + # @api private + def _params(env, params) + params ||= {} + env[PARAMS] ||= {} + env[PARAMS].merge!(Rack::Utils.parse_nested_query(env["QUERY_STRING"])) + env[PARAMS].merge!(params) + env[PARAMS] = Params.deep_symbolize(env[PARAMS]) + env + end - path = path.to_s - endpoint = Routing::Endpoint.find(to, namespace || @namespace, config) - route = Routing::Route.new(verb_for(verb), @prefix.join(path).to_s, endpoint, constraints) + # @since 2.0.0 + # @api private + def _not_allowed_fixed(env) + found = false - @routes.push(route) - @named[as] = route unless as.nil? + @fixed.each_value do |routes| + break if found + + found = routes.key?(env["PATH_INFO"]) + end + + found end - def verb_for(value) - if value == GET - [GET, HEAD] - else - [value] + # @since 2.0.0 + # @api private + def _not_allowed_variable(env) + found = false + + @variable.each_value do |routes| + break if found + + found = routes.find(env["PATH_INFO"]) end + + found end end end