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

- old
+ new

@@ -1,12 +1,12 @@ -require 'rack/request' -require 'hanami/routing/http_router' -require 'hanami/routing/namespace' -require 'hanami/routing/resource' -require 'hanami/routing/resources' -require 'hanami/routing/error' +# frozen_string_literal: true +require "rack/request" +require "dry/inflector" +require "hanami/routing" +require "hanami/utils/hash" + # Hanami # # @since 0.1.0 module Hanami # Rack compatible, lightweight and fast HTTP Router. @@ -73,11 +73,16 @@ # 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 + # + class Router # rubocop:disable Metrics/ClassLength + # @since 2.0.0 + # @api private + attr_reader :inflector + # This error is raised when <tt>#call</tt> is invoked on a non-routable # recognized route. # # @since 0.5.0 # @@ -86,29 +91,29 @@ # @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'.freeze + REQUEST_METHOD = "REQUEST_METHOD" # @since 0.5.0 # @api private - PATH_INFO = 'PATH_INFO'.freeze + PATH_INFO = "PATH_INFO" # @since 0.5.0 def initialize(env) - super %(Cannot find routable endpoint for #{ env[REQUEST_METHOD] } "#{ env[PATH_INFO] }") + 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 = '/'.freeze + ROOT_PATH = "/" # 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. @@ -152,12 +157,12 @@ # 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 [Array<Symbol,String,Object #mime_types, parse>] :parsers - # the body parsers for mime types + # @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 # @@ -176,55 +181,57 @@ # router = Hanami::Router.new do # get '/', to: endpoint # end # # @example Body parsers - # require 'json' - # require 'hanami/router' # - # # It parses JSON body and makes the attributes available to the params + # require 'hanami/router' + # require 'hanami/middleware/body_parser' # - # endpoint = ->(env) { [200, {},[env['router.params'].inspect]] } + # app = Hanami::Router.new do + # patch '/books/:id', to: ->(env) { [200, {},[env['router.params'].inspect]] } + # end # - # router = Hanami::Router.new(parsers: [:json]) do - # patch '/books/:id', to: endpoint - # end + # use Hanami::Middleware::BodyParser, :json + # run app # - # # From the shell + # # From the shell # - # curl http://localhost:2300/books/1 \ - # -H "Content-Type: application/json" \ - # -H "Accept: application/json" \ - # -d '{"published":"true"}' \ - # -X PATCH + # curl http://localhost:2300/books/1 \ + # -H "Content-Type: application/json" \ + # -H "Accept: application/json" \ + # -d '{"published":"true"}' \ + # -X PATCH # # # It returns # # [200, {}, ["{:published=>\"true\",:id=>\"1\"}"]] # # @example Custom body parser - # require 'hanami/router' # - # class XmlParser - # def mime_types - # ['application/xml', 'text/xml'] - # end + # require 'hanami/router' + # require 'hanami/middleware/body_parser' # - # # Parse body and return a Hash - # def parse(body) - # # ... - # end - # end # - # # It parses XML body and makes the attributes available to the params + # class XmlParser < Hanami::Middleware::BodyParser::Parser + # def mime_types + # ['application/xml', 'text/xml'] + # end # - # endpoint = ->(env) { [200, {},[env['router.params'].inspect]] } + # # Parse body and return a Hash + # def parse(body) + # # parse xml + # end + # end # - # router = Hanami::Router.new(parsers: [XmlParser.new]) do - # patch '/authors/:id', to: endpoint + # app = Hanami::Router.new do + # patch '/authors/:id', to: ->(env) { [200, {},[env['router.params'].inspect]] } # 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" \ @@ -232,15 +239,35 @@ # -X PATCH # # # It returns # # [200, {}, ["{:name=>\"LG\",:id=>\"1\"}"]] - def initialize(options = {}, &blk) - @router = Routing::HttpRouter.new(options) - define(&blk) + # + # 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 end + # rubocop:enable Metrics/MethodLength + # Freeze the router + # + # @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. @@ -251,34 +278,10 @@ # @api private def routes self end - # To support defining routes in the `define` wrapper. - # - # @param blk [Proc] the block to define the routes - # - # @return [Hanami::Routing::Route] - # - # @since 0.2.0 - # - # @example In Hanami framework - # class Application < Hanami::Application - # configure do - # routes 'config/routes' - # end - # end - # - # # In `config/routes` - # - # define do - # get # ... - # end - def define(&blk) - instance_eval(&blk) if block_given? - end - # Check if there are defined routes # # @return [TrueClass,FalseClass] the result of the check # # @since 0.2.0 @@ -290,11 +293,11 @@ # router.defined? # => false # # router = Hanami::Router.new { get '/', to: ->(env) { } } # router.defined? # => true def defined? - @router.routes.any? + @routes.any? end # Defines a route that accepts a GET request for the given path. # # @param path [String] the relative URL to be matched @@ -407,12 +410,12 @@ # router = Hanami::Router.new # router.get '/flowers', to: 'flowers#index' # # # It will map to Flowers::Index.new, which is the # # Hanami::Controller convention. - def get(path, options = {}, &blk) - @router.get(path, options, &blk) + def get(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(GET, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts a POST request for the given path. # # @param path [String] the relative URL to be matched @@ -426,12 +429,12 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.1.0 - def post(path, options = {}, &blk) - @router.post(path, options, &blk) + def post(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(POST, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts a PUT request for the given path. # # @param path [String] the relative URL to be matched @@ -445,12 +448,12 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.1.0 - def put(path, options = {}, &blk) - @router.put(path, options, &blk) + def put(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(PUT, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts a PATCH request for the given path. # # @param path [String] the relative URL to be matched @@ -464,12 +467,12 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.1.0 - def patch(path, options = {}, &blk) - @router.patch(path, options, &blk) + def patch(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(PATCH, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts a DELETE request for the given path. # # @param path [String] the relative URL to be matched @@ -483,12 +486,12 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.1.0 - def delete(path, options = {}, &blk) - @router.delete(path, options, &blk) + def delete(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(DELETE, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts a TRACE request for the given path. # # @param path [String] the relative URL to be matched @@ -502,12 +505,12 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.1.0 - def trace(path, options = {}, &blk) - @router.trace(path, options, &blk) + def trace(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(TRACE, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts a LINK request for the given path. # # @param path [String] the relative URL to be matched @@ -521,12 +524,12 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.8.0 - def link(path, options = {}, &blk) - @router.link(path, options, &blk) + def link(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(LINK, path, to, as, namespace, configuration, constraints, &blk) end # Defines a route that accepts an UNLINK request for the given path. # # @param path [String] the relative URL to be matched @@ -540,14 +543,33 @@ # option passed to the constructor # # @see Hanami::Router#get # # @since 0.8.0 - def unlink(path, options = {}, &blk) - @router.unlink(path, options, &blk) + def unlink(path, to: nil, as: nil, namespace: nil, configuration: nil, **constraints, &blk) + add_route(UNLINK, path, to, as, namespace, configuration, constraints, &blk) end + # Defines a route that accepts a OPTIONS 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 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 # @@ -570,33 +592,14 @@ # 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(options = {}, &blk) - @router.get(ROOT_PATH, options.merge(as: :root), &blk) + 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) end - # Defines a route that accepts a OPTIONS 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 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, options = {}, &blk) - @router.options(path, options, &blk) - end - # Defines an HTTP redirect # # @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`) @@ -619,61 +622,79 @@ # @example # require 'hanami/router' # # router = Hanami::Router.new # router.redirect '/legacy', to: '/new_endpoint' - def redirect(path, options = {}, &endpoint) - destination_path = @router.find(options) - get(path).redirect(destination_path, options[:code] || 301).tap do |route| - route.dest = Hanami::Routing::RedirectEndpoint.new(destination_path, route.dest) - end + def redirect(path, to:, code: 301) + to = Routing::Redirect.new(@prefix.join(to).to_s, code) + add_route(GET, path, to) end - # Defines a Ruby block: all the routes defined within it will be namespaced + # Defines a Ruby block: all the routes defined within it will be prefixed # with the given relative path. # - # Namespaces blocks can be nested multiple times. + # Prefix blocks can be nested multiple times. # - # @param namespace [String] the relative path where the nested routes will + # @param path [String] the relative path where the nested routes will # be mounted # @param blk [Proc] the block that defines the resources # - # @return [Hanami::Routing::Namespace] the generated namespace. + # @return [void] # - # @since 0.1.0 + # @since 2.0.0 # # @see Hanami::Router # # @example Basic example - # require 'hanami/router' + # require "hanami/router" # # Hanami::Router.new do - # namespace 'trees' do - # get '/sequoia', to: endpoint # => '/trees/sequoia' + # prefix "trees" do + # get "/sequoia", to: endpoint # => "/trees/sequoia" # end # end # - # @example Nested namespaces - # require 'hanami/router' + # @example Nested prefix + # require "hanami/router" # # Hanami::Router.new do - # namespace 'animals' do - # namespace 'mammals' do - # get '/cats', to: endpoint # => '/animals/mammals/cats' + # 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) + end + + # Defines a scope for routes. # + # A scope is a combination of a path prefix and a Ruby namespace. + # + # @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 + # + # @since 2.0.0 + # @api private + # # @example - # require 'hanami/router' + # require "hanami/router" + # require "hanami/controller" # - # router = Hanami::Router.new - # router.namespace 'trees' do - # get '/sequoia', to: endpoint # => '/trees/sequoia' + # configuration = Hanami::Controller::Configuration.new + # + # Hanami::Router.new do + # scope "/admin", namespace: Admin::Controllers, configuration: configuration do + # root to: "home#index" + # end # end - def namespace(namespace, &blk) - Routing::Namespace.new(self, namespace, &blk) + def scope(prefix, namespace:, configuration:, &blk) + Routing::Scope.new(self, prefix, namespace, configuration, &blk) end # Defines a set of named routes for a single RESTful resource. # It has a built-in integration for Hanami::Controller. # @@ -791,11 +812,11 @@ # # | 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: @router.action_separator), &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. # @@ -914,11 +935,11 @@ # # | 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: @router.action_separator), &blk) + Routing::Resources.new(self, name, options.merge(separator: Routing::Endpoint::ACTION_SEPARATOR), &blk) end # Mount a Rack application at the specified path. # All the requests starting with the specified path, will be forwarded to # the given application. @@ -1002,25 +1023,34 @@ # # 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, options) - @router.mount(app, options) + def mount(app, at:, host: nil) + app = App.new(@prefix.join(at).to_s, Routing::Endpoint.find(app, @namespace), host: host) + @routes.push(app) 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) - @router.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. @@ -1126,18 +1156,15 @@ # route = router.recognize(:book, {method: :post}, {id: 1}) # route.verb # => "POST" # route.routable? # => false # route.params # => {:id=>"1"} def recognize(env, options = {}, params = nil) - require 'hanami/routing/recognized_route' + env = env_for(env, options, params) + # FIXME: this finder is shared with #call and should be extracted + route = @routes.find { |r| r.match?(env) } - env = env_for(env, options, params) - responses, _ = *@router.recognize(env) - - Routing::RecognizedRoute.new( - responses.nil? ? responses : responses.first, - env, @router) + Routing::RecognizedRoute.new(route, env, @namespace) 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. @@ -1159,12 +1186,14 @@ # 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) - @router.path(route, *args) + 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") 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. @@ -1186,12 +1215,12 @@ # 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) - @router.url(route, *args) + def url(route, args = {}) + @base + path(route, args) end # Returns an routes inspector # # @since 0.2.0 @@ -1212,12 +1241,12 @@ # # => 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(@router.routes, @router.prefix) + require "hanami/routing/routes_inspector" + Routing::RoutesInspector.new(@routes, @prefix) end protected # Fabricate Rack env for the given Rack env, path or named route @@ -1231,12 +1260,12 @@ # @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) - env = case env + def env_for(env, options = {}, params = nil) # rubocop:disable Metrics/MethodLength + case env when String Rack::MockRequest.env_for(env, options) when Symbol begin url = path(env, params || options) @@ -1244,9 +1273,93 @@ rescue Hanami::Routing::InvalidRouteException {} end else env + end + end + + private + + PATH_INFO = "PATH_INFO" + SCRIPT_NAME = "SCRIPT_NAME" + SERVER_NAME = "SERVER_NAME" + REQUEST_METHOD = "REQUEST_METHOD" + + PARAMS = "router.params" + + GET = "GET" + HEAD = "HEAD" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" + TRACE = "TRACE" + OPTIONS = "OPTIONS" + LINK = "LINK" + UNLINK = "UNLINK" + + NOT_FOUND = ->(_) { [404, { "Content-Length" => "9" }, ["Not Found"]] }.freeze + NOT_ALLOWED = ->(_) { [405, { "Content-Length" => "18" }, ["Method Not Allowed"]] }.freeze + ROOT = "/" + + BODY = 2 + + attr_reader :configuration + + # 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 match?(env) + match_path?(env) + end + + def match_path?(env) + result = env[PATH_INFO].start_with?(@prefix) + result &&= @host == env[SERVER_NAME] unless @host.nil? + + result + end + + def call(env) + env[PARAMS] ||= {} + env[PARAMS].merge!(Utils::Hash.deep_symbolize(@path.params(env[PATH_INFO]) || {})) + + env[SCRIPT_NAME] = @prefix + env[PATH_INFO] = env[PATH_INFO].sub(@prefix, "") + env[PATH_INFO] = "/" if env[PATH_INFO] == "" + + @endpoint.call(env) + end + end + + def add_route(verb, path, to, as = nil, namespace = nil, config = nil, constraints = {}, &blk) + to ||= blk + config ||= configuration + + 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) + + @routes.push(route) + @named[as] = route unless as.nil? + end + + def verb_for(value) + if value == GET + [GET, HEAD] + else + [value] end end end end