From 92f4787b922937376ae9f1c1e9e0ef5ba1a174a6 Mon Sep 17 00:00:00 2001 From: namusyaka Date: Mon, 1 Feb 2016 03:12:56 +0900 Subject: [PATCH] Replace rack-mount with new router --- CHANGELOG.md | 6 +- README.md | 10 +- grape.gemspec | 2 +- lib/grape.rb | 3 +- lib/grape/api.rb | 52 ++++--- lib/grape/dsl/inside_route.rb | 4 +- lib/grape/dsl/routing.rb | 3 +- lib/grape/endpoint.rb | 103 +++++-------- lib/grape/error_formatter/base.rb | 4 +- lib/grape/http/headers.rb | 1 + .../versioner/accept_version_header.rb | 4 +- lib/grape/middleware/versioner/header.rb | 4 +- lib/grape/middleware/versioner/path.rb | 4 +- lib/grape/namespace.rb | 2 +- lib/grape/path.rb | 6 +- lib/grape/request.rb | 4 +- lib/grape/route.rb | 32 ---- lib/grape/router.rb | 144 ++++++++++++++++++ lib/grape/router/attribute_translator.rb | 40 +++++ lib/grape/router/pattern.rb | 55 +++++++ lib/grape/router/route.rb | 99 ++++++++++++ lib/grape/util/env.rb | 2 +- spec/grape/api_spec.rb | 140 ++++++++++++----- spec/grape/dsl/inside_route_spec.rb | 4 +- spec/grape/entity_spec.rb | 4 +- .../grape/middleware/versioner/header_spec.rb | 2 +- spec/grape/request_spec.rb | 4 +- spec/grape/validations/params_scope_spec.rb | 4 +- spec/grape/validations_spec.rb | 2 +- 29 files changed, 556 insertions(+), 188 deletions(-) delete mode 100644 lib/grape/route.rb create mode 100644 lib/grape/router.rb create mode 100644 lib/grape/router/attribute_translator.rb create mode 100644 lib/grape/router/pattern.rb create mode 100644 lib/grape/router/route.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f67ed232d..f7567868cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ 0.15.1 (Next) ============= +#### Features + +* [#1276](https://github.com/ruby-grape/grape/pull/1276): Replace rack-mount with new router - [@namusyaka](https://github.com/namusyaka). * Your contribution here. +#### Fixes + 0.15.0 (3/8/2016) ================= @@ -32,7 +37,6 @@ * [#1197](https://github.com/ruby-grape/grape/pull/1290): Fix using JSON and Array[JSON] as groups when parameter is optional - [@lukeivers](https://github.com/lukeivers). 0.14.0 (12/07/2015) -=================== #### Features diff --git a/README.md b/README.md index c4e25224ae..248f7a7c78 100644 --- a/README.md +++ b/README.md @@ -2568,11 +2568,15 @@ Examine the routes at runtime. ```ruby TwitterAPI::versions # yields [ 'v1', 'v2' ] TwitterAPI::routes # yields an array of Grape::Route objects -TwitterAPI::routes[0].route_version # => 'v1' -TwitterAPI::routes[0].route_description # => 'Includes custom settings.' -TwitterAPI::routes[0].route_settings[:custom] # => { key: 'value' } +TwitterAPI::routes[0].version # => 'v1' +TwitterAPI::routes[0].description # => 'Includes custom settings.' +TwitterAPI::routes[0].settings[:custom] # => { key: 'value' } ``` +Note that `Route#route_xyz` methods have been deprecated since 0.15.0. + +Please use `Route#xyz` instead. + ## Current Route and Endpoint It's possible to retrieve the information about the current route from within an API call with `route`. diff --git a/grape.gemspec b/grape.gemspec index a6fba1f8ce..ee9fa92156 100644 --- a/grape.gemspec +++ b/grape.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |s| s.license = 'MIT' s.add_runtime_dependency 'rack', '>= 1.3.0' - s.add_runtime_dependency 'rack-mount' + s.add_runtime_dependency 'mustermann19', '~> 0.4.2' s.add_runtime_dependency 'rack-accept' s.add_runtime_dependency 'activesupport' s.add_runtime_dependency 'multi_json', '>= 1.3.2' diff --git a/lib/grape.rb b/lib/grape.rb index fbc8c8b7c5..104319739e 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -1,6 +1,5 @@ require 'logger' require 'rack' -require 'rack/mount' require 'rack/builder' require 'rack/accept' require 'rack/auth/basic' @@ -33,8 +32,8 @@ module Grape eager_autoload do autoload :API autoload :Endpoint + autoload :Router - autoload :Route autoload :Namespace autoload :Path diff --git a/lib/grape/api.rb b/lib/grape/api.rb index a6f0c8b8de..920b827fdb 100644 --- a/lib/grape/api.rb +++ b/lib/grape/api.rb @@ -1,3 +1,5 @@ +require 'grape/router' + module Grape # The API class is the primary entry point for creating Grape APIs. Users # should subclass this class in order to build an API. @@ -5,7 +7,7 @@ class API include Grape::DSL::API class << self - attr_reader :instance + attr_reader :instance, :router # A class-level lock to ensure the API is not compiled by multiple # threads simultaneously within the same process. @@ -87,24 +89,25 @@ def inherit_settings(other_settings) # Builds the routes from the defined endpoints, effectively compiling # this API into a usable form. def initialize - @route_set = Rack::Mount::RouteSet.new + @router = Router.new add_head_not_allowed_methods_and_options_methods self.class.endpoints.each do |endpoint| - endpoint.mount_in(@route_set) + endpoint.mount_in(@router) end - @route_set.freeze + @router.compile! + @router.freeze end # Handle a request. See Rack documentation for what `env` is. def call(env) - result = @route_set.call(env) + result = @router.call(env) result[1].delete(Grape::Http::Headers::X_CASCADE) unless cascade? result end # Some requests may return a HTTP 404 error if grape cannot find a matching - # route. In this case, Rack::Mount adds a X-Cascade header to the response + # route. In this case, Grape::Router adds a X-Cascade header to the response # and sets it to 'pass', indicating to grape's parents they should keep # looking for a matching route on other resources. # @@ -126,20 +129,23 @@ def cascade? # will return an HTTP 405 response for any HTTP method that the resource # cannot handle. def add_head_not_allowed_methods_and_options_methods - methods_per_path = {} + routes_map = {} self.class.endpoints.each do |endpoint| routes = endpoint.routes routes.each do |route| - route_path = route.route_path - .gsub(/\(.*\)/, '') # ignore any optional portions - .gsub(%r{\:[^\/.?]+}, ':x') # substitute variable names to avoid conflicts - - methods_per_path[route_path] ||= [] - methods_per_path[route_path] << route.route_method + # using the :any shorthand produces [nil] for route methods, substitute all manually + route_key = route.pattern.to_regexp + routes_map[route_key] ||= {} + route_settings = routes_map[route_key] + route_settings[:requirements] = route.requirements + route_settings[:path] = route.origin + route_settings[:methods] ||= [] + route_settings[:methods] << route.request_method + route_settings[:endpoint] = route.app # using the :any shorthand produces [nil] for route methods, substitute all manually - methods_per_path[route_path] = %w(GET PUT POST DELETE PATCH HEAD OPTIONS) if methods_per_path[route_path].compact.empty? + route_settings[:methods] = %w(GET PUT POST DELETE PATCH HEAD OPTIONS) if route_settings[:methods].include?('ANY') end end @@ -149,7 +155,9 @@ def add_head_not_allowed_methods_and_options_methods # informations again. without_root_prefix do without_versioning do - methods_per_path.each do |path, methods| + routes_map.each do |regexp, config| + methods = config[:methods] + path = config[:path] allowed_methods = methods.dup unless self.class.namespace_inheritable(:do_not_route_head) @@ -159,18 +167,18 @@ def add_head_not_allowed_methods_and_options_methods allow_header = (self.class.namespace_inheritable(:do_not_route_options) ? allowed_methods : [Grape::Http::Headers::OPTIONS] | allowed_methods).join(', ') unless self.class.namespace_inheritable(:do_not_route_options) - generate_options_method(path, allow_header) unless allowed_methods.include?(Grape::Http::Headers::OPTIONS) + generate_options_method(path, allow_header, config) unless allowed_methods.include?(Grape::Http::Headers::OPTIONS) end - generate_not_allowed_method(path, allowed_methods, allow_header) + generate_not_allowed_method(regexp, allowed_methods, allow_header, config[:endpoint]) end end end end # Generate an 'OPTIONS' route for a pre-exisiting user defined route - def generate_options_method(path, allow_header) - self.class.options(path, {}) do + def generate_options_method(path, allow_header, options = {}) + self.class.options(path, options) do header 'Allow', allow_header status 204 '' @@ -179,15 +187,13 @@ def generate_options_method(path, allow_header) # Generate a route that returns an HTTP 405 response for a user defined # path on methods not specified - def generate_not_allowed_method(path, allowed_methods, allow_header) + def generate_not_allowed_method(path, allowed_methods, allow_header, endpoint = nil) not_allowed_methods = %w(GET PUT POST DELETE PATCH HEAD) - allowed_methods not_allowed_methods << Grape::Http::Headers::OPTIONS if self.class.namespace_inheritable(:do_not_route_options) return if not_allowed_methods.empty? - self.class.route(not_allowed_methods, path) do - fail Grape::Exceptions::MethodNotAllowed, header.merge('Allow' => allow_header) - end + @router.associate_routes(path, not_allowed_methods, allow_header, endpoint) end # Allows definition of endpoints that ignore the versioning configuration diff --git a/lib/grape/dsl/inside_route.rb b/lib/grape/dsl/inside_route.rb index 09b033734f..7d29598198 100644 --- a/lib/grape/dsl/inside_route.rb +++ b/lib/grape/dsl/inside_route.rb @@ -269,10 +269,10 @@ def present(*args) # # desc "Returns the route description." # get '/' do - # route.route_description + # route.description # end def route - env[Grape::Env::RACK_ROUTING_ARGS][:route_info] + env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info] end # Attempt to locate the Entity class for a given object, if not given diff --git a/lib/grape/dsl/routing.rb b/lib/grape/dsl/routing.rb index bbc83fb11d..5e55f1e5d1 100644 --- a/lib/grape/dsl/routing.rb +++ b/lib/grape/dsl/routing.rb @@ -82,7 +82,7 @@ def mount(mounts) in_setting = inheritable_setting if app.respond_to?(:inheritable_setting, true) - mount_path = Rack::Mount::Utils.normalize_path(path) + mount_path = Grape::Router.normalize_path(path) app.top_level_setting.namespace_stackable[:mount_path] = mount_path app.inherit_settings(inheritable_setting) @@ -98,6 +98,7 @@ def mount(mounts) method: :any, path: path, app: app, + forward_match: !app.respond_to?(:inheritable_setting), for: self ) end diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index eda3888968..f7c1bf4aa6 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -110,7 +110,7 @@ def method_name end def routes - @routes ||= endpoints ? endpoints.collect(&:routes).flatten : prepare_routes + @routes ||= endpoints ? endpoints.collect(&:routes).flatten : to_routes end def reset_routes! @@ -119,29 +119,36 @@ def reset_routes! @routes = nil end - def mount_in(route_set) + def mount_in(router) if endpoints - endpoints.each do |e| - e.mount_in(route_set) - end + endpoints.each { |e| e.mount_in(router) } else reset_routes! - routes.each do |route| - methods = [route.route_method] - if !namespace_inheritable(:do_not_route_head) && route.route_method == Grape::Http::Headers::GET + methods = [route.request_method] + if !namespace_inheritable(:do_not_route_head) && route.request_method == Grape::Http::Headers::GET methods << Grape::Http::Headers::HEAD end methods.each do |method| - route_set.add_route(self, { - path_info: route.route_compiled, - request_method: method - }, route_info: route) + unless route.request_method.to_s.upcase == method + route = Grape::Router::Route.new(method, route.origin, route.attributes.to_h) + end + router.append(route.apply(self)) end end end end + def to_routes + route_options = prepare_default_route_attributes + map_routes do |method, path| + path = prepare_path(path) + params = merge_route_options(route_options.merge(suffix: path.suffix)) + route = Router::Route.new(method, path.path, params) + route.apply(self) + end.flatten + end + def prepare_routes_requirements endpoint_requirements = options[:route_options][:requirements] || {} all_requirements = (namespace_stackable(:namespace).map(&:requirements) << endpoint_requirements) @@ -150,41 +157,30 @@ def prepare_routes_requirements end end - def prepare_routes_path_params(path) - path_params = {} - - # named parameters in the api path - regex = Rack::Mount::RegexpWithNamedGroups.new(path) - named_params = regex.named_captures.map { |nc| nc[0] } - %w(version format) - named_params.each { |named_param| path_params[named_param] = '' } + def prepare_default_route_attributes + { + namespace: namespace, + version: prepare_version, + requirements: prepare_routes_requirements, + prefix: namespace_inheritable(:root_prefix), + anchor: options[:route_options].fetch(:anchor, true), + settings: inheritable_setting.route.except(:saved_declared_params, :saved_validations), + forward_match: options[:forward_match] + } + end - # route parameters declared via desc or appended to the api declaration - route_params = options[:route_options][:params] - path_params.merge! route_params if route_params + def prepare_version + version = namespace_inheritable(:version) || [] + return if version.length == 0 + version.length == 1 ? version.first.to_s : version + end - path_params + def merge_route_options(default = {}) + options[:route_options].clone.reverse_merge(default) end - def prepare_routes - options[:method].map do |method| - options[:path].map do |path| - prepared_path = prepare_path(path) - anchor = options[:route_options].fetch(:anchor, true) - path = compile_path(prepared_path, anchor && !options[:app], prepare_routes_requirements) - request_method = (method.to_s.upcase unless method == :any) - - Route.new(options[:route_options].clone.merge( - prefix: namespace_inheritable(:root_prefix), - version: namespace_inheritable(:version) ? namespace_inheritable(:version).join('|') : nil, - namespace: namespace, - method: request_method, - path: prepared_path, - params: prepare_routes_path_params(path), - compiled: path, - settings: inheritable_setting.route.except(:saved_declared_params, :saved_validations) - )) - end - end.flatten + def map_routes + options[:method].map { |method| options[:path].map { |path| yield method, path } } end def prepare_path(path) @@ -196,13 +192,6 @@ def namespace @namespace ||= Namespace.joined_space_path(namespace_stackable(:namespace)) end - def compile_path(prepared_path, anchor = true, requirements = {}) - endpoint_options = {} - endpoint_options[:version] = /#{namespace_inheritable(:version).join('|')}/ if namespace_inheritable(:version) - endpoint_options.merge!(requirements) - Rack::Mount::Strexp.compile(prepared_path, endpoint_options, %w( / . ? ), anchor) - end - def call(env) lazy_initialize! dup.call!(env) @@ -275,11 +264,7 @@ def build_stack (namespace_stackable(:middleware) || []).each do |m| m = m.dup block = m.pop if m.last.is_a?(Proc) - if block - b.use(*m, &block) - else - b.use(*m) - end + block ? b.use(*m, &block) : b.use(*m) end if namespace_inheritable(:version) @@ -305,9 +290,7 @@ def build_stack def build_helpers helpers = namespace_stackable(:helpers) || [] Module.new do - helpers.each do |mod_to_include| - include mod_to_include - end + helpers.each { |mod_to_include| include mod_to_include } end end @@ -348,9 +331,7 @@ def run_validators(validators, request) def run_filters(filters, type = :other) ActiveSupport::Notifications.instrument('endpoint_run_filters.grape', endpoint: self, filters: filters, type: type) do - (filters || []).each do |filter| - instance_eval(&filter) - end + (filters || []).each { |filter| instance_eval(&filter) } end post_extension = DSL::InsideRoute.post_filter_methods(type) extend post_extension if post_extension diff --git a/lib/grape/error_formatter/base.rb b/lib/grape/error_formatter/base.rb index 54af6e6b35..b3cecbf99f 100644 --- a/lib/grape/error_formatter/base.rb +++ b/lib/grape/error_formatter/base.rb @@ -7,10 +7,10 @@ def present(message, env) presenter = env[Grape::Env::API_ENDPOINT].entity_class_for_obj(message, present_options) - unless presenter || env[Grape::Env::RACK_ROUTING_ARGS].nil? + unless presenter || env[Grape::Env::GRAPE_ROUTING_ARGS].nil? # env['api.endpoint'].route does not work when the error occurs within a middleware # the Endpoint does not have a valid env at this moment - http_codes = env[Grape::Env::RACK_ROUTING_ARGS][:route_info].route_http_codes || [] + http_codes = env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info].http_codes || [] found_code = http_codes.find do |http_code| (http_code[0].to_i == env[Grape::Env::API_ENDPOINT].status) && http_code[2].respond_to?(:represent) end if env[Grape::Env::API_ENDPOINT].request diff --git a/lib/grape/http/headers.rb b/lib/grape/http/headers.rb index a50e36a628..905f5d88b5 100644 --- a/lib/grape/http/headers.rb +++ b/lib/grape/http/headers.rb @@ -4,6 +4,7 @@ module Headers # https://github.com/rack/rack/blob/master/lib/rack.rb HTTP_VERSION = 'HTTP_VERSION'.freeze PATH_INFO = 'PATH_INFO'.freeze + REQUEST_METHOD = 'REQUEST_METHOD'.freeze QUERY_STRING = 'QUERY_STRING'.freeze CONTENT_TYPE = 'Content-Type'.freeze diff --git a/lib/grape/middleware/versioner/accept_version_header.rb b/lib/grape/middleware/versioner/accept_version_header.rb index 10559cdd7c..35867ff4c3 100644 --- a/lib/grape/middleware/versioner/accept_version_header.rb +++ b/lib/grape/middleware/versioner/accept_version_header.rb @@ -14,7 +14,7 @@ module Versioner # env['api.version'] => 'v1' # # If version does not match this route, then a 406 is raised with - # X-Cascade header to alert Rack::Mount to attempt the next matched + # X-Cascade header to alert Grape::Router to attempt the next matched # route. class AcceptVersionHeader < Base def before @@ -46,7 +46,7 @@ def strict? end # By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking - # of routes (see [Rack::Mount](https://github.com/josh/rack-mount) for more information). To prevent + # of routes (see Grape::Router) for more information). To prevent # this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`. def cascade? if options[:version_options] && options[:version_options].key?(:cascade) diff --git a/lib/grape/middleware/versioner/header.rb b/lib/grape/middleware/versioner/header.rb index fa3ac907e4..4ef1ba33f4 100644 --- a/lib/grape/middleware/versioner/header.rb +++ b/lib/grape/middleware/versioner/header.rb @@ -20,7 +20,7 @@ module Versioner # env['api.format] => 'json' # # If version does not match this route, then a 406 is raised with - # X-Cascade header to alert Rack::Mount to attempt the next matched + # X-Cascade header to alert Grape::Router to attempt the next matched # route. class Header < Base VENDOR_VERSION_HEADER_REGEX = @@ -154,7 +154,7 @@ def version_options # By default those errors contain an `X-Cascade` header set to `pass`, # which allows nesting and stacking of routes - # (see [Rack::Mount](https://github.com/josh/rack-mount) for more + # (see Grape::Router for more # information). To prevent # this behavior, and not add the `X-Cascade` # header, one can set the `:cascade` option to `false`. def cascade? diff --git a/lib/grape/middleware/versioner/path.rb b/lib/grape/middleware/versioner/path.rb index e342c5e1f5..0f21024905 100644 --- a/lib/grape/middleware/versioner/path.rb +++ b/lib/grape/middleware/versioner/path.rb @@ -28,7 +28,7 @@ def before if prefix && path.index(prefix) == 0 path.sub!(prefix, '') - path = Rack::Mount::Utils.normalize_path(path) + path = Grape::Router.normalize_path(path) end pieces = path.split('/') @@ -41,7 +41,7 @@ def before private def prefix - Rack::Mount::Utils.normalize_path(options[:prefix].to_s) if options[:prefix] + Grape::Router.normalize_path(options[:prefix].to_s) if options[:prefix] end end end diff --git a/lib/grape/namespace.rb b/lib/grape/namespace.rb index 2eba661778..5c06a40fc5 100644 --- a/lib/grape/namespace.rb +++ b/lib/grape/namespace.rb @@ -29,7 +29,7 @@ def self.joined_space(settings) # Join the namespaces from a list of settings to create a path prefix. # @param settings [Array] list of Grape::Util::InheritableSettings. def self.joined_space_path(settings) - Rack::Mount::Utils.normalize_path(joined_space(settings)) + Grape::Router.normalize_path(joined_space(settings)) end end end diff --git a/lib/grape/path.rb b/lib/grape/path.rb index a44e8ad871..49df55822d 100644 --- a/lib/grape/path.rb +++ b/lib/grape/path.rb @@ -2,7 +2,7 @@ module Grape # Represents a path to an endpoint. class Path def self.prepare(raw_path, namespace, settings) - Path.new(raw_path, namespace, settings).path_with_suffix + Path.new(raw_path, namespace, settings) end attr_reader :raw_path, :namespace, :settings @@ -22,7 +22,7 @@ def root_prefix end def uses_specific_format? - !!(settings[:format] && settings[:content_types].size == 1) + !!(settings[:format] && Array(settings[:content_types]).size == 1) end def uses_path_versioning? @@ -48,7 +48,7 @@ def suffix end def path - Rack::Mount::Utils.normalize_path(parts.join('/')) + Grape::Router.normalize_path(parts.join('/')) end def path_with_suffix diff --git a/lib/grape/request.rb b/lib/grape/request.rb index 273fd3d804..a687dfa719 100644 --- a/lib/grape/request.rb +++ b/lib/grape/request.rb @@ -16,8 +16,8 @@ def headers def build_params params = Hashie::Mash.new(rack_params) - if env[Grape::Env::RACK_ROUTING_ARGS] - args = env[Grape::Env::RACK_ROUTING_ARGS].dup + if env[Grape::Env::GRAPE_ROUTING_ARGS] + args = env[Grape::Env::GRAPE_ROUTING_ARGS].dup # preserve version from query string parameters args.delete(:version) args.delete(:route_info) diff --git a/lib/grape/route.rb b/lib/grape/route.rb deleted file mode 100644 index 3a2eb201ad..0000000000 --- a/lib/grape/route.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Grape - # A compiled route for inspection. - class Route - # @api private - def initialize(options = {}) - @options = options || {} - end - - # @api private - def method_missing(method_id, *arguments) - match = /route_([_a-zA-Z]\w*)/.match(method_id.to_s) - if match - @options[match.captures.last.to_sym] - else - super - end - end - - # Generate a short, human-readable representation of this route. - def to_s - "version=#{route_version}, method=#{route_method}, path=#{route_path}" - end - - private - - # This is defined so that certain Ruby methods which attempt to call #to_ary - # on objects, e.g. Array#join, will not hit #method_missing. - def to_ary - nil - end - end -end diff --git a/lib/grape/router.rb b/lib/grape/router.rb new file mode 100644 index 0000000000..73ef9a1d96 --- /dev/null +++ b/lib/grape/router.rb @@ -0,0 +1,144 @@ +require 'grape/router/route' + +module Grape + class Router + attr_reader :map, :compiled + + ReverseRoute = Struct.new(:pattern, :not_allowed_methods, :allowed_methods, :index, :endpoint) + + def initialize + @reverse_map = [] + @map = Hash.new { |hash, key| hash[key] = [] } + @optimized_map = Hash.new { |hash, key| hash[key] = // } + end + + def compile! + return if compiled + @union = Regexp.union(@reverse_map.map(&:pattern)) + map.each do |method, routes| + @optimized_map[method] = routes.map.with_index do |route, index| + route.index = index + route.regexp = /(?<_#{index}>#{route.pattern.to_regexp})/ + end + @optimized_map[method] = Regexp.union(@optimized_map[method]) + end + @compiled = true + end + + def append(route) + map[route.request_method.to_s.upcase] << route + end + + def associate_routes(pattern, not_allowed_methods, allowed_methods, endpoint) + pattern = /(?<_#{@reverse_map.length}>)#{pattern}/ + @reverse_map << ReverseRoute.new(pattern, not_allowed_methods, allowed_methods, @reverse_map.length, endpoint) + end + + def call(env) + with_optimization do + identity(env) || rotation(env) { |route| route.exec(env) } + end + end + + private + + def identity(env) + transaction(env) do |input, method, routing_args| + route = match?(input, method) + if route + env[Grape::Env::GRAPE_ROUTING_ARGS] = make_routing_args(routing_args, route, input) + route.exec(env) + end + end + end + + def rotation(env) + transaction(env) do |input, method, routing_args| + response = nil + routes_for(method).each do |route| + next unless route.match?(input) + env[Grape::Env::GRAPE_ROUTING_ARGS] = make_routing_args(routing_args, route, input) + response = yield(route) + break unless cascade?(response) + end + response + end + end + + def transaction(env) + input, method, routing_args = *extract_required_args(env) + response = yield(input, method, routing_args) + + return response if response && !(cascade = cascade?(response)) + neighbor = greedy_match?(input) + return unless neighbor + + (!cascade && neighbor) ? method_not_allowed(env, neighbor.allowed_methods, neighbor.endpoint) : nil + end + + def make_routing_args(default_args, route, input) + args = default_args || { route_info: route } + args.merge(route.params(input)) + end + + def extract_required_args(env) + input = string_for(env[Grape::Http::Headers::PATH_INFO]) + method = env[Grape::Http::Headers::REQUEST_METHOD] + routing_args = env[Grape::Env::GRAPE_ROUTING_ARGS] + [input, method, routing_args] + end + + def with_optimization + compile! unless compiled + yield || default_response + end + + def default_response + [404, { Grape::Http::Headers::X_CASCADE => 'pass' }, ['404 Not Found']] + end + + def match?(input, method) + current_regexp = @optimized_map[method] + return unless current_regexp.match(input) + last_match = Regexp.last_match + @map[method].detect { |route| last_match["_#{route.index}"] } + end + + def greedy_match?(input) + return unless @union.match(input) + last_match = Regexp.last_match + @reverse_map.detect { |route| last_match["_#{route.index}"] } + end + + def method_not_allowed(env, methods, endpoint) + current = endpoint.dup + current.instance_eval do + run_filters befores, :before + @block = proc do + fail Grape::Exceptions::MethodNotAllowed, header.merge('Allow' => methods) + end + end + current.call(env) + end + + def cascade?(response) + response && response[1][Grape::Http::Headers::X_CASCADE] == 'pass' + end + + def routes_for(method) + map[method] + map['ANY'] + end + + def string_for(input) + self.class.normalize_path(input) + end + + def self.normalize_path(path) + path = "/#{path}" + path.squeeze!('/') + path.sub!(%r{/+\Z}, '') + path = '/' if path == '' + path + end + end +end diff --git a/lib/grape/router/attribute_translator.rb b/lib/grape/router/attribute_translator.rb new file mode 100644 index 0000000000..62b0fde5a8 --- /dev/null +++ b/lib/grape/router/attribute_translator.rb @@ -0,0 +1,40 @@ +require 'delegate' +require 'ostruct' + +module Grape + class Router + class AttributeTranslator < DelegateClass(OpenStruct) + def self.register(*attributes) + AttributeTranslator.supported_attributes.concat(attributes) + end + + def self.supported_attributes + @supported_attributes ||= [] + end + + def initialize(attributes = {}) + ostruct = OpenStruct.new(attributes) + super ostruct + @attributes = attributes + self.class.supported_attributes.each do |name| + ostruct.send(:"#{name}=", nil) unless ostruct.respond_to?(name) + self.class.instance_eval do + define_method(name) { instance_variable_get(:"@#{name}") } + end if name == :format + end + end + + def to_h + @attributes.each_with_object({}) do |(key, _), attributes| + attributes[key.to_sym] = send(:"#{key}") + end + end + + private + + def accessor_available?(name) + respond_to?(name) && respond_to?(:"#{name}=") + end + end + end +end diff --git a/lib/grape/router/pattern.rb b/lib/grape/router/pattern.rb new file mode 100644 index 0000000000..4e3ca75b91 --- /dev/null +++ b/lib/grape/router/pattern.rb @@ -0,0 +1,55 @@ +require 'forwardable' +require 'mustermann/grape' + +module Grape + class Router + class Pattern + DEFAULT_PATTERN_OPTIONS = { uri_decode: true, type: :grape }.freeze + DEFAULT_SUPPORTED_CAPTURE = [:format, :version].freeze + + attr_reader :origin, :path, :capture, :pattern + + extend Forwardable + def_delegators :pattern, :named_captures, :params + def_delegators :@regexp, :=== + alias_method :match?, :=== + + def initialize(pattern, options = {}) + @origin = pattern + @path = build_path(pattern, options) + @capture = extract_capture(options) + @pattern = Mustermann.new(@path, pattern_options) + @regexp = to_regexp + end + + def to_regexp + @to_regexp ||= @pattern.to_regexp + end + + private + + def pattern_options + options = DEFAULT_PATTERN_OPTIONS.dup + options.merge!(capture: capture) if capture.present? + options + end + + def build_path(pattern, options = {}) + pattern << '*path' unless options[:anchor] || pattern.end_with?('*path') + pattern + options[:suffix].to_s + end + + def extract_capture(options = {}) + requirements = {}.merge(options[:requirements]) + supported_capture.each_with_object(requirements) do |field, capture| + option = Array(options[field]) + capture[field] = option.map(&:to_s) if option.present? + end + end + + def supported_capture + DEFAULT_SUPPORTED_CAPTURE + end + end + end +end diff --git a/lib/grape/router/route.rb b/lib/grape/router/route.rb new file mode 100644 index 0000000000..712c03c999 --- /dev/null +++ b/lib/grape/router/route.rb @@ -0,0 +1,99 @@ +require 'grape/router/pattern' +require 'grape/router/attribute_translator' +require 'forwardable' +require 'pathname' + +module Grape + class Router + class Route + ROUTE_ATTRIBUTE_REGEXP = /route_([_a-zA-Z]\w*)/.freeze + SOURCE_LOCATION_REGEXP = /^(.*?):(\d+?)(?::in `.+?')?$/.freeze + TRANSLATION_ATTRIBUTES = [ + :prefix, + :version, + :namespace, + :settings, + :format, + :description, + :http_codes, + :headers, + :entity, + :details, + :requirements, + :request_method + ].freeze + + attr_accessor :pattern, :translator, :app, :index, :regexp + + alias_method :attributes, :translator + + extend Forwardable + def_delegators :pattern, :path, :origin + + def self.translate(*attributes) + AttributeTranslator.register(*attributes) + def_delegators :@translator, *attributes + end + + translate(*TRANSLATION_ATTRIBUTES) + + def method_missing(method_id, *arguments) + match = ROUTE_ATTRIBUTE_REGEXP.match(method_id.to_s) + if match + method_name = match.captures.last.to_sym + warn_route_methods(method_name, caller(1).shift) + @options[method_name] + else + super + end + end + + def route_path + warn_route_methods(:path, caller(1).shift) + pattern.path + end + + def initialize(method, pattern, options = {}) + @suffix = options[:suffix] + @options = options.merge(method: method.to_s.upcase) + @pattern = Pattern.new(pattern, options) + @translator = AttributeTranslator.new(options.merge(request_method: method.to_s.upcase)) + end + + def exec(env) + @app.call(env) + end + + def apply(app) + @app = app + self + end + + def match?(input) + translator.respond_to?(:forward_match) && translator.forward_match ? input.start_with?(pattern.origin) : pattern.match?(input) + end + + def params(input = nil) + if input.nil? + default = pattern.named_captures.keys.each_with_object({}) do |key, defaults| + defaults[key] = '' + end + default.delete_if { |key, _| key == 'format' }.merge(translator.params) + else + parsed = pattern.params(input) + parsed ? parsed.delete_if { |_, value| value.nil? }.symbolize_keys : {} + end + end + + private + + def warn_route_methods(name, location) + path, line = *location.scan(SOURCE_LOCATION_REGEXP).first + path = File.realpath(path) if Pathname.new(path).relative? + warn <<-EOS +#{path}:#{line}: The route_xxx methods such as route_#{name} have been deprecated, please use #{name}. + EOS + end + end + end +end diff --git a/lib/grape/util/env.rb b/lib/grape/util/env.rb index e31e85015d..fb0206ff52 100644 --- a/lib/grape/util/env.rb +++ b/lib/grape/util/env.rb @@ -13,10 +13,10 @@ module Env RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash'.freeze RACK_REQUEST_FORM_HASH = 'rack.request.form_hash'.freeze RACK_REQUEST_FORM_INPUT = 'rack.request.form_input'.freeze - RACK_ROUTING_ARGS = 'rack.routing_args'.freeze GRAPE_REQUEST = 'grape.request'.freeze GRAPE_REQUEST_HEADERS = 'grape.request.headers'.freeze GRAPE_REQUEST_PARAMS = 'grape.request.params'.freeze + GRAPE_ROUTING_ARGS = 'grape.routing_args'.freeze end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 7934f222ae..756b94d1b9 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -411,7 +411,7 @@ def subject.enable_root_route! end subject.endpoints.first.routes.each do |route| - expect(route.route_path).to eql '/abc(.:format)' + expect(route.path).to eql '/abc(.:format)' end get '/abc' @@ -567,6 +567,72 @@ def subject.enable_root_route! expect(last_response.headers['Content-Type']).to eql 'text/plain' end + describe 'adds an OPTIONS route that' do + before do + subject.before { header 'X-Custom-Header', 'foo' } + subject.get 'example' do + 'example' + end + subject.route :any, '*path' do + error! :not_found, 404 + end + options '/example' + end + + it 'returns a 204' do + expect(last_response.status).to eql 204 + end + + it 'has an empty body' do + expect(last_response.body).to be_blank + end + + it 'has an Allow header' do + expect(last_response.headers['Allow']).to eql 'OPTIONS, GET, HEAD' + end + + it 'has a X-Custom-Header' do + expect(last_response.headers['X-Custom-Header']).to eql 'foo' + end + + it 'has no Content-Type' do + expect(last_response.content_type).to be_nil + end + + it 'has no Content-Length' do + expect(last_response.content_length).to be_nil + end + end + + describe 'adds a 405 Not Allowed route that' do + before do + subject.before { header 'X-Custom-Header', 'foo' } + subject.post :example do + 'example' + end + subject.route :any, '*path' do + error! :not_found, 404 + end + get '/example' + end + + it 'returns a 405' do + expect(last_response.status).to eql 405 + end + + it 'contains error message in body' do + expect(last_response.body).to eq '405 Not Allowed' + end + + it 'has an Allow header' do + expect(last_response.headers['Allow']).to eql 'OPTIONS, POST' + end + + it 'has a X-Custom-Header' do + expect(last_response.headers['X-Custom-Header']).to eql 'foo' + end + end + context 'allows HEAD on a GET request that' do before do subject.get 'example' do @@ -2006,9 +2072,9 @@ def static it 'returns one route' do expect(subject.routes.size).to eq(1) route = subject.routes[0] - expect(route.route_version).to be_nil - expect(route.route_path).to eq('/ping(.:format)') - expect(route.route_method).to eq('GET') + expect(route.version).to be_nil + expect(route.path).to eq('/ping(.:format)') + expect(route.request_method).to eq('GET') end end describe 'api structure with two versions and a namespace' do @@ -2036,18 +2102,18 @@ def static end it 'sets route paths' do expect(subject.routes.size).to be >= 2 - expect(subject.routes[0].route_path).to eq('/:version/version(.:format)') - expect(subject.routes[1].route_path).to eq('/p/:version/n1/n2/version(.:format)') + expect(subject.routes[0].path).to eq('/:version/version(.:format)') + expect(subject.routes[1].path).to eq('/p/:version/n1/n2/version(.:format)') end it 'sets route versions' do - expect(subject.routes[0].route_version).to eq('v1') - expect(subject.routes[1].route_version).to eq('v2') + expect(subject.routes[0].version).to eq('v1') + expect(subject.routes[1].version).to eq('v2') end it 'sets a nested namespace' do - expect(subject.routes[1].route_namespace).to eq('/n1/n2') + expect(subject.routes[1].namespace).to eq('/n1/n2') end it 'sets prefix' do - expect(subject.routes[1].route_prefix).to eq('p') + expect(subject.routes[1].prefix).to eq('p') end end describe 'api structure with additional parameters' do @@ -2068,9 +2134,9 @@ def static get '/split/a,b,c.json', token: ',', limit: '2' expect(last_response.body).to eq('["a","b,c"]') end - it 'sets route_params' do + it 'sets params' do expect(subject.routes.map { |route| - { params: route.route_params } + { params: route.params } }).to eq [ { params: { @@ -2098,9 +2164,9 @@ def static subject.get 'two' do end end - it 'sets route_params' do + it 'sets params' do expect(subject.routes.map { |route| - { params: route.route_params } + { params: route.params } }).to eq [ { params: { @@ -2129,9 +2195,9 @@ def static subject.get 'two' do end end - it 'sets route_params' do + it 'sets params' do expect(subject.routes.map { |route| - { params: route.route_params } + { params: route.params } }).to eq [ { params: { @@ -2153,7 +2219,7 @@ def static it 'exposed' do expect(subject.routes.count).to eq 1 route = subject.routes.first - expect(route.route_settings[:custom]).to eq(key: 'value') + expect(route.settings[:custom]).to eq(key: 'value') end end describe 'status' do @@ -2187,9 +2253,9 @@ def static subject.get :first do; end expect(subject.routes.length).to eq(1) route = subject.routes.first - expect(route.route_description).to eq('first method') + expect(route.description).to eq('first method') expect(route.route_foo).to be_nil - expect(route.route_params).to eq({}) + expect(route.params).to eq({}) end it 'describes methods separately' do subject.desc 'first method' @@ -2198,7 +2264,7 @@ def static subject.get :second do; end expect(subject.routes.count).to eq(2) expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: 'first method', params: {} }, { description: 'second method', params: {} } @@ -2209,7 +2275,7 @@ def static subject.get :first do; end subject.get :second do; end expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: 'first method', params: {} }, { description: nil, params: {} } @@ -2221,7 +2287,7 @@ def static get 'second' do; end end expect(subject.routes.map { |route| - { description: route.route_description, foo: route.route_foo, params: route.route_params } + { description: route.description, foo: route.route_foo, params: route.params } }).to eq [ { description: 'ns second', foo: 'bar', params: {} } ] @@ -2230,7 +2296,7 @@ def static subject.desc 'method', details: 'method details' subject.get 'method' do; end expect(subject.routes.map { |route| - { description: route.route_description, details: route.route_details, params: route.route_params } + { description: route.description, details: route.details, params: route.params } }).to eq [ { description: 'method', details: 'method details', params: {} } ] @@ -2241,7 +2307,7 @@ def static params[:s].reverse end expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: 'Reverses a string.', params: { 's' => { desc: 'string to reverse', type: 'string' } } } ] @@ -2262,7 +2328,7 @@ def static get do; end end routes_doc = subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } } expect(routes_doc).to eq [ { description: 'global description', @@ -2292,7 +2358,7 @@ def static end routes_doc = subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } } expect(routes_doc).to eq [ { description: 'method', @@ -2324,7 +2390,7 @@ def static end end expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: 'method', params: { @@ -2350,7 +2416,7 @@ def static end subject.get 'method' do; end - expect(subject.routes.map(&:route_params)).to eq [{ + expect(subject.routes.map(&:params)).to eq [{ 'group1' => { required: true, type: 'Array' }, 'group1[param1]' => { required: false, desc: 'group1 param1 desc' }, 'group1[param2]' => { required: true, desc: 'group1 param2 desc' }, @@ -2369,7 +2435,7 @@ def static end subject.get 'method' do; end expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: 'nesting', params: { @@ -2393,7 +2459,7 @@ def static end subject.get 'method' do; end expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: nil, params: { 'one_param' => { required: true, desc: 'one param' } } } ] @@ -2404,7 +2470,7 @@ def static params[:s].reverse end expect(subject.routes.map { |route| - { description: route.route_description, params: route.route_params } + { description: route.description, params: route.params } }).to eq [ { description: 'Reverses a string.', params: { 's' => { desc: 'string to reverse', type: 'string' } } } ] @@ -2512,8 +2578,8 @@ def static mount app end expect(subject.routes.size).to eq(2) - expect(subject.routes.first.route_path).to match(%r{\/cool\/awesome}) - expect(subject.routes.last.route_path).to match(%r{\/cool\/sauce}) + expect(subject.routes.first.path).to match(%r{\/cool\/awesome}) + expect(subject.routes.last.path).to match(%r{\/cool\/sauce}) end it 'mounts on a path' do @@ -2732,10 +2798,10 @@ def static context 'plain' do before(:each) do subject.get '/' do - route.route_path + route.path end subject.get '/path' do - route.route_path + route.path end end it 'provides access to route info' do @@ -2749,11 +2815,11 @@ def static before(:each) do subject.desc 'returns description' subject.get '/description' do - route.route_description + route.description end subject.desc 'returns parameters', params: { 'x' => 'y' } subject.get '/params/:id' do - route.route_params[params[:id]] + route.params[params[:id]] end end it 'returns route description' do diff --git a/spec/grape/dsl/inside_route_spec.rb b/spec/grape/dsl/inside_route_spec.rb index 3c300e71ca..046bebe87a 100644 --- a/spec/grape/dsl/inside_route_spec.rb +++ b/spec/grape/dsl/inside_route_spec.rb @@ -230,8 +230,8 @@ def initialize describe '#route' do before do - subject.env['rack.routing_args'] = {} - subject.env['rack.routing_args'][:route_info] = 'dummy' + subject.env['grape.routing_args'] = {} + subject.env['grape.routing_args'][:route_info] = 'dummy' end it 'returns route_info' do diff --git a/spec/grape/entity_spec.rb b/spec/grape/entity_spec.rb index d97ef91ea3..674e228a35 100644 --- a/spec/grape/entity_spec.rb +++ b/spec/grape/entity_spec.rb @@ -170,7 +170,7 @@ def first end [:json, :serializable_hash].each do |format| - it 'presents with #{format}' do + it "presents with #{format}" do entity = Class.new(Grape::Entity) entity.root 'examples', 'example' entity.expose :id @@ -191,7 +191,7 @@ def initialize(id) expect(last_response.body).to eq('{"example":{"id":1}}') end - it 'presents with #{format} collection' do + it "presents with #{format} collection" do entity = Class.new(Grape::Entity) entity.root 'examples', 'example' entity.expose :id diff --git a/spec/grape/middleware/versioner/header_spec.rb b/spec/grape/middleware/versioner/header_spec.rb index 45bbe910d8..e908784811 100644 --- a/spec/grape/middleware/versioner/header_spec.rb +++ b/spec/grape/middleware/versioner/header_spec.rb @@ -50,7 +50,7 @@ end ['v1', :v1].each do |version| - context 'when version is set to #{version{ ' do + context "when version is set to #{version}" do before do @options[:versions] = [version] end diff --git a/spec/grape/request_spec.rb b/spec/grape/request_spec.rb index acceaee294..57a1cae069 100644 --- a/spec/grape/request_spec.rb +++ b/spec/grape/request_spec.rb @@ -34,9 +34,9 @@ module Grape expect(request.params).to eq('a' => '123', 'b' => 'xyz') end - describe 'with rack.routing_args' do + describe 'with grape.routing_args' do let(:options) { - default_options.merge('rack.routing_args' => routing_args) + default_options.merge('grape.routing_args' => routing_args) } let(:routing_args) { { diff --git a/spec/grape/validations/params_scope_spec.rb b/spec/grape/validations/params_scope_spec.rb index 5d0dacf872..f26daa826b 100644 --- a/spec/grape/validations/params_scope_spec.rb +++ b/spec/grape/validations/params_scope_spec.rb @@ -10,7 +10,7 @@ def app end context 'setting a default' do - let(:documentation) { subject.routes.first.route_params } + let(:documentation) { subject.routes.first.params } context 'when the default value is truthy' do before do @@ -67,7 +67,7 @@ def app end it 'does not add documentation for the default value' do - documentation = subject.routes.first.route_params + documentation = subject.routes.first.params expect(documentation).to have_key('object') expect(documentation['object']).not_to have_key(:default) end diff --git a/spec/grape/validations_spec.rb b/spec/grape/validations_spec.rb index f82f43bf66..cd6437832c 100644 --- a/spec/grape/validations_spec.rb +++ b/spec/grape/validations_spec.rb @@ -1074,7 +1074,7 @@ def validate_param!(attr_name, params) subject.get '/' do end - expect(subject.routes.first.route_params['first_name'][:documentation]).to eq(documentation) + expect(subject.routes.first.params['first_name'][:documentation]).to eq(documentation) end end