From 443245fd24ac8131fbb7ecb80d951c9d078579d8 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 12:23:40 +1000 Subject: [PATCH 01/20] Update TargetRubyVersion to 3.4 in .rubocop.yml --- .rubocop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 9930e5608..139b6988a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,7 +6,7 @@ AllCops: - example/**/* UseCache: true NewCops: enable - TargetRubyVersion: 3.3 + TargetRubyVersion: 3.4 SuggestExtensions: false # Layout stuff From dea80a88b95f7d9ccb71f09cfdfd1c801f576b98 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 12:24:22 +1000 Subject: [PATCH 02/20] Rename Grape::Entity classes for clarity and update documentation references --- spec/issues/537_enum_values_spec.rb | 10 +++++----- spec/issues/579_align_put_post_parameters_spec.rb | 14 +++++++------- .../api_swagger_v2_param_type_body_spec.rb | 2 +- spec/swagger_v2/params_example_spec.rb | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/spec/issues/537_enum_values_spec.rb b/spec/issues/537_enum_values_spec.rb index 6e1bf168f..2ebe85b64 100644 --- a/spec/issues/537_enum_values_spec.rb +++ b/spec/issues/537_enum_values_spec.rb @@ -6,14 +6,14 @@ let(:app) do Class.new(Grape::API) do namespace :issue_537 do - class Spec < Grape::Entity + class Issue537Spec < Grape::Entity expose :enum_property, documentation: { values: %i[foo bar] } expose :enum_property_default, documentation: { values: %w[a b c], default: 'c' } expose :own_format, documentation: { format: 'log' } end desc 'create account', - success: Spec + success: Issue537Spec get do { message: 'hi' } end @@ -28,13 +28,13 @@ class Spec < Grape::Entity JSON.parse(last_response.body) end - let(:property) { subject['definitions']['Spec']['properties']['enum_property'] } + let(:property) { subject['definitions']['Issue537Spec']['properties']['enum_property'] } specify do expect(property).to include 'enum' expect(property['enum']).to eql %w[foo bar] end - let(:property_default) { subject['definitions']['Spec']['properties']['enum_property_default'] } + let(:property_default) { subject['definitions']['Issue537Spec']['properties']['enum_property_default'] } specify do expect(property_default).to include 'enum' expect(property_default['enum']).to eql %w[a b c] @@ -42,7 +42,7 @@ class Spec < Grape::Entity expect(property_default['default']).to eql 'c' end - let(:own_format) { subject['definitions']['Spec']['properties']['own_format'] } + let(:own_format) { subject['definitions']['Issue537Spec']['properties']['own_format'] } specify do expect(own_format).to include 'format' expect(own_format['format']).to eql 'log' diff --git a/spec/issues/579_align_put_post_parameters_spec.rb b/spec/issues/579_align_put_post_parameters_spec.rb index 466f06565..cb555d409 100644 --- a/spec/issues/579_align_put_post_parameters_spec.rb +++ b/spec/issues/579_align_put_post_parameters_spec.rb @@ -12,7 +12,7 @@ class BodySpec < Grape::Entity expose :content, documentation: { type: String, in: 'body' } end - class Spec < Grape::Entity + class Issue579Spec < Grape::Entity expose :guid, documentation: { type: String, format: 'guid' } expose :name, documentation: { type: String } expose :content, documentation: { type: String } @@ -32,8 +32,8 @@ class Spec < Grape::Entity namespace :form_parameter do desc 'update spec', consumes: ['application/x-www-form-urlencoded'], - success: Spec, - params: Spec.documentation + success: Issue579Spec, + params: Issue579Spec.documentation put ':guid' do # your code goes here end @@ -57,8 +57,8 @@ class Spec < Grape::Entity namespace :form_parameter do desc 'update spec', consumes: ['application/x-www-form-urlencoded'], - success: Spec, - params: Spec.documentation + success: Issue579Spec, + params: Issue579Spec.documentation params do requires :guid end @@ -83,8 +83,8 @@ class Spec < Grape::Entity namespace :form_parameter do desc 'update spec', consumes: ['application/x-www-form-urlencoded'], - success: Spec, - params: Spec.documentation + success: Issue579Spec, + params: Issue579Spec.documentation put do # your code goes here end diff --git a/spec/swagger_v2/api_swagger_v2_param_type_body_spec.rb b/spec/swagger_v2/api_swagger_v2_param_type_body_spec.rb index cf394f99e..32eaae098 100644 --- a/spec/swagger_v2/api_swagger_v2_param_type_body_spec.rb +++ b/spec/swagger_v2/api_swagger_v2_param_type_body_spec.rb @@ -59,7 +59,7 @@ class BodyParamTypeApi < Grape::API namespace :with_entity_param do desc 'put in body with entity parameter' params do - optional :data, type: ::Entities::NestedModule::ApiResponse, documentation: { desc: 'request data' } + optional :data, type: String, documentation: { type: ::Entities::NestedModule::ApiResponse, desc: 'request data' } end post do diff --git a/spec/swagger_v2/params_example_spec.rb b/spec/swagger_v2/params_example_spec.rb index fbddf7cca..e58dc6dab 100644 --- a/spec/swagger_v2/params_example_spec.rb +++ b/spec/swagger_v2/params_example_spec.rb @@ -11,7 +11,7 @@ def app params :common_params do requires :id, type: Integer, documentation: { example: 123 } optional :name, type: String, documentation: { example: 'Person' } - optional :obj, type: 'Object', documentation: { example: { 'foo' => 'bar' } } + optional :obj, type: Hash, documentation: { type: 'Object', example: { 'foo' => 'bar' } } end end From ca5fdb20f7a96df808bd9d7a4ee23fcd229853c0 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:09:37 +1000 Subject: [PATCH 03/20] [cop] Add SwaggerRouting and SwaggerDocumentationAdder modules --- lib/grape-swagger.rb | 164 +----------------- .../swagger_documentation_adder.rb | 67 +++++++ lib/grape-swagger/swagger_routing.rb | 97 +++++++++++ 3 files changed, 166 insertions(+), 162 deletions(-) create mode 100644 lib/grape-swagger/swagger_documentation_adder.rb create mode 100644 lib/grape-swagger/swagger_routing.rb diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index 9e929b25f..75c452a31 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -11,6 +11,8 @@ require 'grape-swagger/doc_methods' require 'grape-swagger/model_parsers' require 'grape-swagger/request_param_parser_registry' +require 'grape-swagger/swagger_routing' +require 'grape-swagger/swagger_documentation_adder' require 'grape-swagger/token_owner_resolver' module GrapeSwagger @@ -44,166 +46,4 @@ def request_param_parsers }.freeze end -module SwaggerRouting - private - - def combine_routes(app, doc_klass) - app.routes.each_with_object({}) do |route, combined_routes| - route_path = route.path - route_match = route_path.split(/^.*?#{route.prefix}/).last - next unless route_match - - # want to match emojis … ;) - # route_match = route_match - # .match('\/([\p{Alnum}p{Emoji}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\p{Emoji}\-\_]*)$') - route_match = route_match.match('\/([\p{Alnum}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\-\_]*)$') - next unless route_match - - resource = route_match.captures.first - resource = '/' if resource.empty? - combined_routes[resource] ||= [] - next if doc_klass.hide_documentation_path && route.path.match(/#{doc_klass.mount_path}($|\/|\(\.)/) - - combined_routes[resource] << route - end - end - - def determine_namespaced_routes(name, parent_route, routes) - return routes.values.flatten if parent_route.nil? - - parent_route.select do |route| - route_path_start_with?(route, name) || route_namespace_equals?(route, name) - end - end - - def combine_namespace_routes(namespaces, routes) - combined_namespace_routes = {} - # iterate over each single namespace - namespaces.each_key do |name, _| - # get the parent route for the namespace - parent_route_name = extract_parent_route(name) - parent_route = routes[parent_route_name] - # fetch all routes that are within the current namespace - namespace_routes = determine_namespaced_routes(name, parent_route, routes) - - # default case when not explicitly specified or nested == true - standalone_namespaces = namespaces.reject do |_, ns| - !ns.options.key?(:swagger) || - !ns.options[:swagger].key?(:nested) || - ns.options[:swagger][:nested] != false - end - - parent_standalone_namespaces = standalone_namespaces.select { |ns_name, _| name.start_with?(ns_name) } - # add only to the main route - # if the namespace is not within any other namespace appearing as standalone resource - # rubocop:disable Style/Next - if parent_standalone_namespaces.empty? - # default option, append namespace methods to parent route - combined_namespace_routes[parent_route_name] ||= [] - combined_namespace_routes[parent_route_name].push(*namespace_routes) - end - # rubocop:enable Style/Next - end - - combined_namespace_routes - end - - def extract_parent_route(name) - route_name = name.match(%r{^/?([^/]*).*$})[1] - return route_name unless route_name.include? ':' - - matches = name.match(/\/\p{Alpha}+/) - matches.nil? ? route_name : matches[0].delete('/') - end - - def route_namespace_equals?(route, name) - patterns = Enumerator.new do |yielder| - yielder << "/#{name}" - yielder << "/:version/#{name}" - end - - patterns.any? { |p| route.namespace == p } - end - - def route_path_start_with?(route, name) - patterns = Enumerator.new do |yielder| - if route.prefix - yielder << "/#{route.prefix}/#{name}" - yielder << "/#{route.prefix}/:version/#{name}" - else - yielder << "/#{name}" - yielder << "/:version/#{name}" - end - end - - patterns.any? { |p| route.path.start_with?(p) } - end -end - -module SwaggerDocumentationAdder - attr_accessor :combined_namespaces, :combined_routes, :combined_namespace_routes - - include SwaggerRouting - - def add_swagger_documentation(options = {}) - documentation_class = create_documentation_class - - version_for(options) - options = { target_class: self }.merge(options) - @target_class = options[:target_class] - auth_wrapper = options[:endpoint_auth_wrapper] || Class.new - - use auth_wrapper if auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper) - - documentation_class.setup(options) - mount(documentation_class) - - combined_routes = combine_routes(@target_class, documentation_class) - combined_namespaces = combine_namespaces(@target_class) - combined_namespace_routes = combine_namespace_routes(combined_namespaces, combined_routes) - exclusive_route_keys = combined_routes.keys - combined_namespaces.keys - @target_class.combined_namespace_routes = combined_namespace_routes.merge( - combined_routes.slice(*exclusive_route_keys) - ) - @target_class.combined_routes = combined_routes - @target_class.combined_namespaces = combined_namespaces - - documentation_class - end - - private - - def version_for(options) - options[:version] = version if version - end - - def combine_namespaces(app) - combined_namespaces = {} - endpoints = app.endpoints.clone - - while endpoints.any? - endpoint = endpoints.shift - - endpoints.push(*endpoint.options[:app].endpoints) if endpoint.options[:app] - namespace_stackable = endpoint.inheritable_setting.namespace_stackable - ns = (namespace_stackable[:namespace] || []).last - next unless ns - - # use the full namespace here (not the latest level only) - # and strip leading slash - mount_path = (namespace_stackable[:mount_path] || []).join('/') - full_namespace = (mount_path + endpoint.namespace).sub(/\/{2,}/, '/').sub(/^\//, '') - combined_namespaces[full_namespace] = ns - end - - combined_namespaces - end - - def create_documentation_class - Class.new(GrapeInstance) do - extend GrapeSwagger::DocMethods - end - end -end - GrapeInstance.extend(SwaggerDocumentationAdder) diff --git a/lib/grape-swagger/swagger_documentation_adder.rb b/lib/grape-swagger/swagger_documentation_adder.rb new file mode 100644 index 000000000..da97c7ebc --- /dev/null +++ b/lib/grape-swagger/swagger_documentation_adder.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module SwaggerDocumentationAdder + attr_accessor :combined_namespaces, :combined_routes, :combined_namespace_routes + + include SwaggerRouting + + def add_swagger_documentation(options = {}) + documentation_class = create_documentation_class + + version_for(options) + options = { target_class: self }.merge(options) + @target_class = options[:target_class] + auth_wrapper = options[:endpoint_auth_wrapper] || Class.new + + use auth_wrapper if auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper) + + documentation_class.setup(options) + mount(documentation_class) + + combined_routes = combine_routes(@target_class, documentation_class) + combined_namespaces = combine_namespaces(@target_class) + combined_namespace_routes = combine_namespace_routes(combined_namespaces, combined_routes) + exclusive_route_keys = combined_routes.keys - combined_namespaces.keys + @target_class.combined_namespace_routes = combined_namespace_routes.merge( + combined_routes.slice(*exclusive_route_keys) + ) + @target_class.combined_routes = combined_routes + @target_class.combined_namespaces = combined_namespaces + + documentation_class + end + + private + + def version_for(options) + options[:version] = version if version + end + + def combine_namespaces(app) + combined_namespaces = {} + endpoints = app.endpoints.clone + + while endpoints.any? + endpoint = endpoints.shift + + endpoints.push(*endpoint.options[:app].endpoints) if endpoint.options[:app] + namespace_stackable = endpoint.inheritable_setting.namespace_stackable + ns = (namespace_stackable[:namespace] || []).last + next unless ns + + # use the full namespace here (not the latest level only) + # and strip leading slash + mount_path = (namespace_stackable[:mount_path] || []).join('/') + full_namespace = (mount_path + endpoint.namespace).sub(/\/{2,}/, '/').sub(/^\//, '') + combined_namespaces[full_namespace] = ns + end + + combined_namespaces + end + + def create_documentation_class + Class.new(GrapeInstance) do + extend GrapeSwagger::DocMethods + end + end +end diff --git a/lib/grape-swagger/swagger_routing.rb b/lib/grape-swagger/swagger_routing.rb new file mode 100644 index 000000000..4c4353a98 --- /dev/null +++ b/lib/grape-swagger/swagger_routing.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module SwaggerRouting + private + + def combine_routes(app, doc_klass) + app.routes.each_with_object({}) do |route, combined_routes| + route_path = route.path + route_match = route_path.split(/^.*?#{route.prefix}/).last + next unless route_match + + # want to match emojis ... ;) + # route_match = route_match + # .match('\/([\p{Alnum}p{Emoji}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\p{Emoji}\-\_]*)$') + route_match = route_match.match('\/([\p{Alnum}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\-\_]*)$') + next unless route_match + + resource = route_match.captures.first + resource = '/' if resource.empty? + combined_routes[resource] ||= [] + next if doc_klass.hide_documentation_path && route.path.match(/#{doc_klass.mount_path}($|\/|\(\.)/) + + combined_routes[resource] << route + end + end + + def determine_namespaced_routes(name, parent_route, routes) + return routes.values.flatten if parent_route.nil? + + parent_route.select do |route| + route_path_start_with?(route, name) || route_namespace_equals?(route, name) + end + end + + def combine_namespace_routes(namespaces, routes) + combined_namespace_routes = {} + # iterate over each single namespace + namespaces.each_key do |name, _| + # get the parent route for the namespace + parent_route_name = extract_parent_route(name) + parent_route = routes[parent_route_name] + # fetch all routes that are within the current namespace + namespace_routes = determine_namespaced_routes(name, parent_route, routes) + + # default case when not explicitly specified or nested == true + standalone_namespaces = namespaces.reject do |_, ns| + !ns.options.key?(:swagger) || + !ns.options[:swagger].key?(:nested) || + ns.options[:swagger][:nested] != false + end + + parent_standalone_namespaces = standalone_namespaces.select { |ns_name, _| name.start_with?(ns_name) } + # add only to the main route + # if the namespace is not within any other namespace appearing as standalone resource + # rubocop:disable Style/Next + if parent_standalone_namespaces.empty? + # default option, append namespace methods to parent route + combined_namespace_routes[parent_route_name] ||= [] + combined_namespace_routes[parent_route_name].push(*namespace_routes) + end + # rubocop:enable Style/Next + end + + combined_namespace_routes + end + + def extract_parent_route(name) + route_name = name.match(%r{^/?([^/]*).*$})[1] + return route_name unless route_name.include? ':' + + matches = name.match(/\/\p{Alpha}+/) + matches.nil? ? route_name : matches[0].delete('/') + end + + def route_namespace_equals?(route, name) + patterns = Enumerator.new do |yielder| + yielder << "/#{name}" + yielder << "/:version/#{name}" + end + + patterns.any? { |p| route.namespace == p } + end + + def route_path_start_with?(route, name) + patterns = Enumerator.new do |yielder| + if route.prefix + yielder << "/#{route.prefix}/#{name}" + yielder << "/#{route.prefix}/:version/#{name}" + else + yielder << "/#{name}" + yielder << "/:version/#{name}" + end + end + + patterns.any? { |p| route.path.start_with?(p) } + end +end From 30b65e2fba446ec7f26ad2d601b2862f28c973db Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:15:57 +1000 Subject: [PATCH 04/20] Update CHANGELOG for Ruby 3.4 and refactor of swagger documentation modules --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428ddb86e..2fb739c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### Features +* [#976](https://github.com/ruby-grape/grape-swagger/pull/976): Ruby 3.4 and refactor swagger documentation modules - [@moskvin](https://github.com/moskvin). * Your contribution here. #### Fixes From 93c4886a5355046542ac4cf4cff0132164316285 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:19:43 +1000 Subject: [PATCH 05/20] Enhance route parameter handling with fallback support and add unit tests --- .../request_param_parsers/route.rb | 32 +++++++++++++- spec/lib/request_param_parsers/route_spec.rb | 44 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 spec/lib/request_param_parsers/route_spec.rb diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index d3d27d20c..5c6bf8eb1 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -49,7 +49,7 @@ def fetch_inherited_params(stackable_values) def fulfill_params(path_params) # Merge path params options into route params - route.params.each_with_object({}) do |(param, definition), accum| + route_params.each_with_object({}) do |(param, definition), accum| # The route.params hash includes both parametrized params (with a string as a key) # and well-defined params from body/query (with a symbol as a key). # We avoid overriding well-defined params with parametrized ones. @@ -57,10 +57,38 @@ def fulfill_params(path_params) next if param.is_a?(String) && accum.key?(key) defined_options = definition.is_a?(Hash) ? definition : {} - value = (path_params[param] || {}).merge(defined_options) + path_options = path_params[param] || path_params[param.to_s] || path_params[param.to_sym] || {} + value = path_options.merge(defined_options) accum[key] = value.empty? ? DEFAULT_PARAM_TYPE : value end end + + def route_params + route.params + rescue NoMethodError => e + raise unless e.message.include?('named_captures') + + fallback_route_params + end + + def fallback_route_params + path_params = extract_path_param_names.to_h { |param| [param, {}] } + defined_params = route.respond_to?(:options) ? route.options[:params] : nil + return path_params unless defined_params.is_a?(Hash) + + path_params.merge(defined_params) + end + + def extract_path_param_names + return [] unless route.respond_to?(:pattern_regexp) + + regexp = route.pattern_regexp + return [] unless regexp.respond_to?(:named_captures) + + regexp.named_captures.keys + rescue StandardError + [] + end end end end diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb new file mode 100644 index 000000000..5e2ea167f --- /dev/null +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +describe GrapeSwagger::RequestParamParsers::Route do + let(:route) { instance_double('route', app: nil) } + + describe '#parse' do + subject(:parse_request_params) { described_class.parse(route, nil, nil, nil) } + + context 'when route params include both path-derived and explicitly defined keys' do + before do + allow(route).to receive(:params).and_return( + 'id' => {}, + id: { required: true, type: 'String' }, + name: { required: false, type: 'String' } + ) + end + + it 'keeps explicitly defined params over inferred string path params' do + expect(parse_request_params).to eq( + id: { required: true, type: 'String' }, + name: { required: false, type: 'String' } + ) + end + end + + context 'when route.params fails due missing named_captures support' do + before do + allow(route).to receive(:params).and_raise( + NoMethodError, + "undefined method `named_captures' for an instance of Mustermann::Grape" + ) + allow(route).to receive(:pattern_regexp).and_return(%r{\A/(?[^/]+)\z}) + allow(route).to receive(:options).and_return(params: { 'name' => { required: false, type: 'String' } }) + end + + it 'falls back to pattern captures and route options params' do + expect(parse_request_params).to eq( + id: { required: true, type: 'Integer' }, + name: { required: false, type: 'String' } + ) + end + end + end +end From 3ea5f1169f70b7126d156e00918abd619a826c18 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:22:20 +1000 Subject: [PATCH 06/20] Add fallback handling for path param extraction errors in route specs --- spec/lib/request_param_parsers/route_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 5e2ea167f..33c2dd713 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -40,5 +40,22 @@ ) end end + + context 'when path param extraction raises an error' do + before do + allow(route).to receive(:params).and_raise( + NoMethodError, + "undefined method `named_captures' for an instance of Mustermann::Grape" + ) + allow(route).to receive(:pattern_regexp).and_raise(StandardError, 'failed to build regexp') + allow(route).to receive(:options).and_return(params: { 'name' => { required: false, type: 'String' } }) + end + + it 'falls back to options params without raising' do + expect(parse_request_params).to eq( + name: { required: false, type: 'String' } + ) + end + end end end From bc01086ee8945c73863469171e4e5612094fbb67 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:24:11 +1000 Subject: [PATCH 07/20] Add deprecation warning for additionalProperties option in parse_params_spec --- spec/lib/parse_params_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/lib/parse_params_spec.rb b/spec/lib/parse_params_spec.rb index efb1de84a..51e82f71c 100644 --- a/spec/lib/parse_params_spec.rb +++ b/spec/lib/parse_params_spec.rb @@ -79,4 +79,19 @@ end end end + + describe '#parse_additional_properties' do + let(:definitions) { {} } + + it 'warns and reads deprecated additionalProperties option' do + settings = { additionalProperties: true } + allow(GrapeSwagger::Errors::SwaggerSpecDeprecated).to receive(:tell!) + + parsed = subject.send(:parse_additional_properties, definitions, settings) + + expect(GrapeSwagger::Errors::SwaggerSpecDeprecated) + .to have_received(:tell!).with(:additionalProperties) + expect(parsed).to eql([true, true]) + end + end end From 7b7a033c9e69d0c39e01f7155246ac2ea82b265c Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:25:13 +1000 Subject: [PATCH 08/20] Enhance path parameter extraction with fallback support and add unit tests --- .../request_param_parsers/route.rb | 15 +++++++++++---- spec/lib/request_param_parsers/route_spec.rb | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index 5c6bf8eb1..8976b8c7e 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -80,14 +80,21 @@ def fallback_route_params end def extract_path_param_names - return [] unless route.respond_to?(:pattern_regexp) + return extract_path_param_names_from_path unless route.respond_to?(:pattern_regexp) regexp = route.pattern_regexp - return [] unless regexp.respond_to?(:named_captures) + return extract_path_param_names_from_path unless regexp.respond_to?(:named_captures) - regexp.named_captures.keys + names = regexp.named_captures.keys + names.empty? ? extract_path_param_names_from_path : names rescue StandardError - [] + extract_path_param_names_from_path + end + + def extract_path_param_names_from_path + return [] unless route.respond_to?(:path) + + route.path.scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/).flatten.reject { |name| name == 'format' } end end end diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 33c2dd713..6fc30104b 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -57,5 +57,24 @@ ) end end + + context 'when fallback extracts path params from route.path' do + before do + allow(route).to receive(:params).and_raise( + NoMethodError, + "undefined method `named_captures' for an instance of Mustermann::Grape" + ) + allow(route).to receive(:pattern_regexp).and_raise(StandardError, 'failed to build regexp') + allow(route).to receive(:path).and_return('/bookings/:id(.json)') + allow(route).to receive(:options).and_return(params: { 'name' => { required: false, type: 'String' } }) + end + + it 'keeps inferred path params when regexp extraction is not available' do + expect(parse_request_params).to eq( + id: { required: true, type: 'Integer' }, + name: { required: false, type: 'String' } + ) + end + end end end From 5913ba7034d37323ad57fe08d8d848f722ba5c38 Mon Sep 17 00:00:00 2001 From: Niko M Date: Mon, 11 May 2026 13:30:02 +1000 Subject: [PATCH 09/20] Add handling for ignored fallback path parameters and enhance route specs --- .../request_param_parsers/route.rb | 6 +++++- spec/lib/request_param_parsers/route_spec.rb | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index 8976b8c7e..b69a818d2 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -4,6 +4,7 @@ module GrapeSwagger module RequestParamParsers class Route DEFAULT_PARAM_TYPE = { required: true, type: 'Integer' }.freeze + IGNORED_FALLBACK_PATH_PARAMS = %w[format version].freeze attr_reader :route @@ -94,7 +95,10 @@ def extract_path_param_names def extract_path_param_names_from_path return [] unless route.respond_to?(:path) - route.path.scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/).flatten.reject { |name| name == 'format' } + route.path + .scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/) + .flatten + .reject { |name| IGNORED_FALLBACK_PATH_PARAMS.include?(name) } end end end diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 6fc30104b..96188c033 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -76,5 +76,23 @@ ) end end + + context 'when route path contains an implicit version placeholder' do + before do + allow(route).to receive(:params).and_raise( + NoMethodError, + "undefined method `named_captures' for an instance of Mustermann::Grape" + ) + allow(route).to receive(:pattern_regexp).and_raise(StandardError, 'failed to build regexp') + allow(route).to receive(:path).and_return('/:version/other_thing/:elements(.json)') + allow(route).to receive(:options).and_return(params: { 'elements' => { required: true, type: 'Array[String]' } }) + end + + it 'does not add version as a synthetic request parameter' do + expect(parse_request_params).to eq( + elements: { required: true, type: 'Array[String]' } + ) + end + end end end From c46756f7ee8a3ac217f0700aa6a189cdf86bee7d Mon Sep 17 00:00:00 2001 From: Niko M Date: Tue, 12 May 2026 11:59:14 +1000 Subject: [PATCH 10/20] :ambulance: Remove weird fallback path parameter handling and clean up route specs --- .../request_param_parsers/route.rb | 40 +-------- spec/lib/request_param_parsers/route_spec.rb | 89 +++++-------------- 2 files changed, 22 insertions(+), 107 deletions(-) diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index b69a818d2..734bd2bbb 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -4,7 +4,6 @@ module GrapeSwagger module RequestParamParsers class Route DEFAULT_PARAM_TYPE = { required: true, type: 'Integer' }.freeze - IGNORED_FALLBACK_PATH_PARAMS = %w[format version].freeze attr_reader :route @@ -50,7 +49,7 @@ def fetch_inherited_params(stackable_values) def fulfill_params(path_params) # Merge path params options into route params - route_params.each_with_object({}) do |(param, definition), accum| + route.params.each_with_object({}) do |(param, definition), accum| # The route.params hash includes both parametrized params (with a string as a key) # and well-defined params from body/query (with a symbol as a key). # We avoid overriding well-defined params with parametrized ones. @@ -63,43 +62,6 @@ def fulfill_params(path_params) accum[key] = value.empty? ? DEFAULT_PARAM_TYPE : value end end - - def route_params - route.params - rescue NoMethodError => e - raise unless e.message.include?('named_captures') - - fallback_route_params - end - - def fallback_route_params - path_params = extract_path_param_names.to_h { |param| [param, {}] } - defined_params = route.respond_to?(:options) ? route.options[:params] : nil - return path_params unless defined_params.is_a?(Hash) - - path_params.merge(defined_params) - end - - def extract_path_param_names - return extract_path_param_names_from_path unless route.respond_to?(:pattern_regexp) - - regexp = route.pattern_regexp - return extract_path_param_names_from_path unless regexp.respond_to?(:named_captures) - - names = regexp.named_captures.keys - names.empty? ? extract_path_param_names_from_path : names - rescue StandardError - extract_path_param_names_from_path - end - - def extract_path_param_names_from_path - return [] unless route.respond_to?(:path) - - route.path - .scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/) - .flatten - .reject { |name| IGNORED_FALLBACK_PATH_PARAMS.include?(name) } - end end end end diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 96188c033..593696f9e 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -6,91 +6,44 @@ describe '#parse' do subject(:parse_request_params) { described_class.parse(route, nil, nil, nil) } - context 'when route params include both path-derived and explicitly defined keys' do - before do - allow(route).to receive(:params).and_return( - 'id' => {}, - id: { required: true, type: 'String' }, - name: { required: false, type: 'String' } - ) - end - - it 'keeps explicitly defined params over inferred string path params' do - expect(parse_request_params).to eq( - id: { required: true, type: 'String' }, - name: { required: false, type: 'String' } - ) - end - end + context 'when inherited namespace stackable values contain path params across levels' do + let(:root_stackable) { Grape::Util::StackableValues.new } + let(:nested_stackable) { Grape::Util::StackableValues.new(root_stackable) } + let(:inheritable_setting) { instance_double('inheritable_setting', namespace_stackable: nested_stackable) } + let(:app) { instance_double('app', inheritable_setting: inheritable_setting) } - context 'when route.params fails due missing named_captures support' do before do - allow(route).to receive(:params).and_raise( - NoMethodError, - "undefined method `named_captures' for an instance of Mustermann::Grape" - ) - allow(route).to receive(:pattern_regexp).and_return(%r{\A/(?[^/]+)\z}) - allow(route).to receive(:options).and_return(params: { 'name' => { required: false, type: 'String' } }) - end + root_stackable[:namespace] = instance_double('namespace', space: ':account_id', options: { required: true, type: 'Integer' }) + nested_stackable[:namespace] = instance_double('namespace', space: ':id', options: { required: true, type: 'String' }) - it 'falls back to pattern captures and route options params' do - expect(parse_request_params).to eq( - id: { required: true, type: 'Integer' }, - name: { required: false, type: 'String' } - ) - end - end - - context 'when path param extraction raises an error' do - before do - allow(route).to receive(:params).and_raise( - NoMethodError, - "undefined method `named_captures' for an instance of Mustermann::Grape" + allow(route).to receive(:app).and_return(app) + allow(route).to receive(:params).and_return( + 'account_id' => {}, + 'id' => {} ) - allow(route).to receive(:pattern_regexp).and_raise(StandardError, 'failed to build regexp') - allow(route).to receive(:options).and_return(params: { 'name' => { required: false, type: 'String' } }) end - it 'falls back to options params without raising' do + it 'merges path params from the full inherited stackable chain' do expect(parse_request_params).to eq( - name: { required: false, type: 'String' } + account_id: { required: true, type: 'Integer' }, + id: { required: true, type: 'String' } ) end end - context 'when fallback extracts path params from route.path' do + context 'when route params include both path-derived and explicitly defined keys' do before do - allow(route).to receive(:params).and_raise( - NoMethodError, - "undefined method `named_captures' for an instance of Mustermann::Grape" - ) - allow(route).to receive(:pattern_regexp).and_raise(StandardError, 'failed to build regexp') - allow(route).to receive(:path).and_return('/bookings/:id(.json)') - allow(route).to receive(:options).and_return(params: { 'name' => { required: false, type: 'String' } }) - end - - it 'keeps inferred path params when regexp extraction is not available' do - expect(parse_request_params).to eq( - id: { required: true, type: 'Integer' }, + allow(route).to receive(:params).and_return( + 'id' => {}, + id: { required: true, type: 'String' }, name: { required: false, type: 'String' } ) end - end - - context 'when route path contains an implicit version placeholder' do - before do - allow(route).to receive(:params).and_raise( - NoMethodError, - "undefined method `named_captures' for an instance of Mustermann::Grape" - ) - allow(route).to receive(:pattern_regexp).and_raise(StandardError, 'failed to build regexp') - allow(route).to receive(:path).and_return('/:version/other_thing/:elements(.json)') - allow(route).to receive(:options).and_return(params: { 'elements' => { required: true, type: 'Array[String]' } }) - end - it 'does not add version as a synthetic request parameter' do + it 'keeps explicitly defined params over inferred string path params' do expect(parse_request_params).to eq( - elements: { required: true, type: 'Array[String]' } + id: { required: true, type: 'String' }, + name: { required: false, type: 'String' } ) end end From 12822ca3a6a478aee4dc0254f9cc520fe2a5b845 Mon Sep 17 00:00:00 2001 From: Niko M Date: Tue, 12 May 2026 12:02:09 +1000 Subject: [PATCH 11/20] Remove outdated Ruby and Grape version combinations from CI matrix --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aef71158..4dee3f212 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,6 @@ jobs: strategy: matrix: entry: - - { ruby: '3.1', grape: '1.8.0' } - - { ruby: '3.2', grape: '1.8.0' } - - { ruby: '3.3', grape: '1.8.0' } - - { ruby: '3.4', grape: '1.8.0' } - { ruby: '3.1', grape: '2.0.0' } - { ruby: '3.2', grape: '2.0.0' } - { ruby: '3.3', grape: '2.0.0' } From 85509fc0167bd29a1c88dd9cf5d7688245f778a4 Mon Sep 17 00:00:00 2001 From: Niko M Date: Tue, 12 May 2026 12:06:56 +1000 Subject: [PATCH 12/20] Update CI matrix with new Ruby and Grape version combinations --- .github/workflows/ci.yml | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dee3f212..bf672eb8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,19 +26,22 @@ jobs: strategy: matrix: entry: - - { ruby: '3.1', grape: '2.0.0' } - - { ruby: '3.2', grape: '2.0.0' } - - { ruby: '3.3', grape: '2.0.0' } - - { ruby: '3.4', grape: '2.0.0' } - - { ruby: '3.1', grape: '2.1.3' } - - { ruby: '3.2', grape: '2.1.3' } - - { ruby: '3.3', grape: '2.1.3' } - - { ruby: '3.4', grape: '2.1.3' } - - { ruby: '3.1', grape: '2.2.0' } - - { ruby: '3.2', grape: '2.2.0' } - - { ruby: '3.3', grape: '2.2.0' } - - { ruby: '3.4', grape: '2.2.0' } - - { ruby: 'head', grape: '2.2.0' } + - { ruby: '3.1', grape: '2.4.0' } + - { ruby: '3.2', grape: '2.4.0' } + - { ruby: '3.3', grape: '2.4.0' } + - { ruby: '3.4', grape: '2.4.0' } + - { ruby: '3.1', grape: '3.0.1' } + - { ruby: '3.2', grape: '3.0.1' } + - { ruby: '3.3', grape: '3.0.1' } + - { ruby: '3.4', grape: '3.0.1' } + - { ruby: '3.1', grape: '3.1.1' } + - { ruby: '3.2', grape: '3.1.1' } + - { ruby: '3.3', grape: '3.1.1' } + - { ruby: '3.4', grape: '3.1.1' } + - { ruby: '3.2', grape: '3.2.1' } + - { ruby: '3.3', grape: '3.2.1' } + - { ruby: '3.4', grape: '3.2.1' } + - { ruby: 'head', grape: '3.2.1' } - { ruby: '3.2', grape: 'HEAD' } - { ruby: '3.3', grape: 'HEAD' } - { ruby: '3.4', grape: 'HEAD' } From 983db7f34ae74d55616b124b9910fd4b276b41c6 Mon Sep 17 00:00:00 2001 From: Niko M Date: Tue, 12 May 2026 12:14:47 +1000 Subject: [PATCH 13/20] :sparkles: Add tests for fulfilling path parameters with symbol and string keys --- spec/lib/request_param_parsers/route_spec.rb | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 593696f9e..8954f334b 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -2,6 +2,7 @@ describe GrapeSwagger::RequestParamParsers::Route do let(:route) { instance_double('route', app: nil) } + let(:parser) { described_class.new(route, nil, nil, nil) } describe '#parse' do subject(:parse_request_params) { described_class.parse(route, nil, nil, nil) } @@ -48,4 +49,40 @@ end end end + + describe '#fulfill_params' do + subject(:fulfilled_params) { parser.send(:fulfill_params, path_params) } + + context 'when route.params uses symbol keys and path params use string keys' do + let(:path_params) { { 'id' => { required: true, type: 'Integer' } } } + + before do + allow(route).to receive(:params).and_return( + id: {} + ) + end + + it 'looks up path options via param.to_s' do + expect(fulfilled_params).to eq( + id: { required: true, type: 'Integer' } + ) + end + end + + context 'when route.params uses string keys and path params use symbol keys' do + let(:path_params) { { id: { required: true, type: 'Integer' } } } + + before do + allow(route).to receive(:params).and_return( + 'id' => {} + ) + end + + it 'looks up path options via param.to_sym' do + expect(fulfilled_params).to eq( + id: { required: true, type: 'Integer' } + ) + end + end + end end From d1f38ec2d54dc981d7effb3a423ef2d94e645cb7 Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:27:11 +1000 Subject: [PATCH 14/20] :recycle: Refactor Swagger documentation module to use GrapeSwagger namespace --- lib/grape-swagger.rb | 2 +- .../swagger_documentation_adder.rb | 96 +++++------ lib/grape-swagger/swagger_routing.rb | 154 +++++++++--------- 3 files changed, 128 insertions(+), 124 deletions(-) diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index 75c452a31..69b852aea 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -46,4 +46,4 @@ def request_param_parsers }.freeze end -GrapeInstance.extend(SwaggerDocumentationAdder) +GrapeInstance.extend(GrapeSwagger::SwaggerDocumentationAdder) diff --git a/lib/grape-swagger/swagger_documentation_adder.rb b/lib/grape-swagger/swagger_documentation_adder.rb index da97c7ebc..5022d2acd 100644 --- a/lib/grape-swagger/swagger_documentation_adder.rb +++ b/lib/grape-swagger/swagger_documentation_adder.rb @@ -1,67 +1,69 @@ # frozen_string_literal: true -module SwaggerDocumentationAdder - attr_accessor :combined_namespaces, :combined_routes, :combined_namespace_routes +module GrapeSwagger + module SwaggerDocumentationAdder + attr_accessor :combined_namespaces, :combined_routes, :combined_namespace_routes - include SwaggerRouting + include GrapeSwagger::SwaggerRouting - def add_swagger_documentation(options = {}) - documentation_class = create_documentation_class + def add_swagger_documentation(options = {}) + documentation_class = create_documentation_class - version_for(options) - options = { target_class: self }.merge(options) - @target_class = options[:target_class] - auth_wrapper = options[:endpoint_auth_wrapper] || Class.new + version_for(options) + options = { target_class: self }.merge(options) + @target_class = options[:target_class] + auth_wrapper = options[:endpoint_auth_wrapper] || Class.new - use auth_wrapper if auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper) + use auth_wrapper if auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper) - documentation_class.setup(options) - mount(documentation_class) + documentation_class.setup(options) + mount(documentation_class) - combined_routes = combine_routes(@target_class, documentation_class) - combined_namespaces = combine_namespaces(@target_class) - combined_namespace_routes = combine_namespace_routes(combined_namespaces, combined_routes) - exclusive_route_keys = combined_routes.keys - combined_namespaces.keys - @target_class.combined_namespace_routes = combined_namespace_routes.merge( - combined_routes.slice(*exclusive_route_keys) - ) - @target_class.combined_routes = combined_routes - @target_class.combined_namespaces = combined_namespaces + combined_routes = combine_routes(@target_class, documentation_class) + combined_namespaces = combine_namespaces(@target_class) + combined_namespace_routes = combine_namespace_routes(combined_namespaces, combined_routes) + exclusive_route_keys = combined_routes.keys - combined_namespaces.keys + @target_class.combined_namespace_routes = combined_namespace_routes.merge( + combined_routes.slice(*exclusive_route_keys) + ) + @target_class.combined_routes = combined_routes + @target_class.combined_namespaces = combined_namespaces - documentation_class - end + documentation_class + end - private + private - def version_for(options) - options[:version] = version if version - end + def version_for(options) + options[:version] = version if version + end - def combine_namespaces(app) - combined_namespaces = {} - endpoints = app.endpoints.clone + def combine_namespaces(app) + combined_namespaces = {} + endpoints = app.endpoints.clone - while endpoints.any? - endpoint = endpoints.shift + while endpoints.any? + endpoint = endpoints.shift - endpoints.push(*endpoint.options[:app].endpoints) if endpoint.options[:app] - namespace_stackable = endpoint.inheritable_setting.namespace_stackable - ns = (namespace_stackable[:namespace] || []).last - next unless ns + endpoints.push(*endpoint.options[:app].endpoints) if endpoint.options[:app] + namespace_stackable = endpoint.inheritable_setting.namespace_stackable + ns = (namespace_stackable[:namespace] || []).last + next unless ns - # use the full namespace here (not the latest level only) - # and strip leading slash - mount_path = (namespace_stackable[:mount_path] || []).join('/') - full_namespace = (mount_path + endpoint.namespace).sub(/\/{2,}/, '/').sub(/^\//, '') - combined_namespaces[full_namespace] = ns - end + # use the full namespace here (not the latest level only) + # and strip leading slash + mount_path = (namespace_stackable[:mount_path] || []).join('/') + full_namespace = (mount_path + endpoint.namespace).sub(/\/{2,}/, '/').sub(/^\//, '') + combined_namespaces[full_namespace] = ns + end - combined_namespaces - end + combined_namespaces + end - def create_documentation_class - Class.new(GrapeInstance) do - extend GrapeSwagger::DocMethods + def create_documentation_class + Class.new(GrapeInstance) do + extend GrapeSwagger::DocMethods + end end end end diff --git a/lib/grape-swagger/swagger_routing.rb b/lib/grape-swagger/swagger_routing.rb index 4c4353a98..22c04a622 100644 --- a/lib/grape-swagger/swagger_routing.rb +++ b/lib/grape-swagger/swagger_routing.rb @@ -1,97 +1,99 @@ # frozen_string_literal: true -module SwaggerRouting - private - - def combine_routes(app, doc_klass) - app.routes.each_with_object({}) do |route, combined_routes| - route_path = route.path - route_match = route_path.split(/^.*?#{route.prefix}/).last - next unless route_match - - # want to match emojis ... ;) - # route_match = route_match - # .match('\/([\p{Alnum}p{Emoji}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\p{Emoji}\-\_]*)$') - route_match = route_match.match('\/([\p{Alnum}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\-\_]*)$') - next unless route_match - - resource = route_match.captures.first - resource = '/' if resource.empty? - combined_routes[resource] ||= [] - next if doc_klass.hide_documentation_path && route.path.match(/#{doc_klass.mount_path}($|\/|\(\.)/) - - combined_routes[resource] << route +module GrapeSwagger + module SwaggerRouting + private + + def combine_routes(app, doc_klass) + app.routes.each_with_object({}) do |route, combined_routes| + route_path = route.path + route_match = route_path.split(/^.*?#{route.prefix}/).last + next unless route_match + + # want to match emojis ... ;) + # route_match = route_match + # .match('\/([\p{Alnum}p{Emoji}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\p{Emoji}\-\_]*)$') + route_match = route_match.match('\/([\p{Alnum}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\-\_]*)$') + next unless route_match + + resource = route_match.captures.first + resource = '/' if resource.empty? + combined_routes[resource] ||= [] + next if doc_klass.hide_documentation_path && route.path.match(/#{doc_klass.mount_path}($|\/|\(\.)/) + + combined_routes[resource] << route + end end - end - def determine_namespaced_routes(name, parent_route, routes) - return routes.values.flatten if parent_route.nil? + def determine_namespaced_routes(name, parent_route, routes) + return routes.values.flatten if parent_route.nil? - parent_route.select do |route| - route_path_start_with?(route, name) || route_namespace_equals?(route, name) + parent_route.select do |route| + route_path_start_with?(route, name) || route_namespace_equals?(route, name) + end end - end - def combine_namespace_routes(namespaces, routes) - combined_namespace_routes = {} - # iterate over each single namespace - namespaces.each_key do |name, _| - # get the parent route for the namespace - parent_route_name = extract_parent_route(name) - parent_route = routes[parent_route_name] - # fetch all routes that are within the current namespace - namespace_routes = determine_namespaced_routes(name, parent_route, routes) - - # default case when not explicitly specified or nested == true - standalone_namespaces = namespaces.reject do |_, ns| - !ns.options.key?(:swagger) || - !ns.options[:swagger].key?(:nested) || - ns.options[:swagger][:nested] != false + def combine_namespace_routes(namespaces, routes) + combined_namespace_routes = {} + # iterate over each single namespace + namespaces.each_key do |name, _| + # get the parent route for the namespace + parent_route_name = extract_parent_route(name) + parent_route = routes[parent_route_name] + # fetch all routes that are within the current namespace + namespace_routes = determine_namespaced_routes(name, parent_route, routes) + + # default case when not explicitly specified or nested == true + standalone_namespaces = namespaces.reject do |_, ns| + !ns.options.key?(:swagger) || + !ns.options[:swagger].key?(:nested) || + ns.options[:swagger][:nested] != false + end + + parent_standalone_namespaces = standalone_namespaces.select { |ns_name, _| name.start_with?(ns_name) } + # add only to the main route + # if the namespace is not within any other namespace appearing as standalone resource + # rubocop:disable Style/Next + if parent_standalone_namespaces.empty? + # default option, append namespace methods to parent route + combined_namespace_routes[parent_route_name] ||= [] + combined_namespace_routes[parent_route_name].push(*namespace_routes) + end + # rubocop:enable Style/Next end - parent_standalone_namespaces = standalone_namespaces.select { |ns_name, _| name.start_with?(ns_name) } - # add only to the main route - # if the namespace is not within any other namespace appearing as standalone resource - # rubocop:disable Style/Next - if parent_standalone_namespaces.empty? - # default option, append namespace methods to parent route - combined_namespace_routes[parent_route_name] ||= [] - combined_namespace_routes[parent_route_name].push(*namespace_routes) - end - # rubocop:enable Style/Next + combined_namespace_routes end - combined_namespace_routes - end - - def extract_parent_route(name) - route_name = name.match(%r{^/?([^/]*).*$})[1] - return route_name unless route_name.include? ':' + def extract_parent_route(name) + route_name = name.match(%r{^/?([^/]*).*$})[1] + return route_name unless route_name.include? ':' - matches = name.match(/\/\p{Alpha}+/) - matches.nil? ? route_name : matches[0].delete('/') - end - - def route_namespace_equals?(route, name) - patterns = Enumerator.new do |yielder| - yielder << "/#{name}" - yielder << "/:version/#{name}" + matches = name.match(/\/\p{Alpha}+/) + matches.nil? ? route_name : matches[0].delete('/') end - patterns.any? { |p| route.namespace == p } - end - - def route_path_start_with?(route, name) - patterns = Enumerator.new do |yielder| - if route.prefix - yielder << "/#{route.prefix}/#{name}" - yielder << "/#{route.prefix}/:version/#{name}" - else + def route_namespace_equals?(route, name) + patterns = Enumerator.new do |yielder| yielder << "/#{name}" yielder << "/:version/#{name}" end + + patterns.any? { |p| route.namespace == p } end - patterns.any? { |p| route.path.start_with?(p) } + def route_path_start_with?(route, name) + patterns = Enumerator.new do |yielder| + if route.prefix + yielder << "/#{route.prefix}/#{name}" + yielder << "/#{route.prefix}/:version/#{name}" + else + yielder << "/#{name}" + yielder << "/:version/#{name}" + end + end + + patterns.any? { |p| route.path.start_with?(p) } + end end end From 03b9ec425d96458b09333e938a8823a4decbdeb8 Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:29:38 +1000 Subject: [PATCH 15/20] :sparkles: Normalize path parameter keys to use symbols and update related tests --- lib/grape-swagger/request_param_parsers/route.rb | 4 ++-- spec/lib/request_param_parsers/route_spec.rb | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index 734bd2bbb..6cc0f42f8 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -43,7 +43,7 @@ def fetch_inherited_params(stackable_values) namespaces.each_with_object({}) do |namespace, params| space = namespace.space.to_s.gsub(':', '') - params[space] = namespace.options || {} + params[space.to_sym] = namespace.options || {} end end @@ -57,7 +57,7 @@ def fulfill_params(path_params) next if param.is_a?(String) && accum.key?(key) defined_options = definition.is_a?(Hash) ? definition : {} - path_options = path_params[param] || path_params[param.to_s] || path_params[param.to_sym] || {} + path_options = path_params[key] || {} value = path_options.merge(defined_options) accum[key] = value.empty? ? DEFAULT_PARAM_TYPE : value end diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 8954f334b..627d2b0f1 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -53,8 +53,8 @@ describe '#fulfill_params' do subject(:fulfilled_params) { parser.send(:fulfill_params, path_params) } - context 'when route.params uses symbol keys and path params use string keys' do - let(:path_params) { { 'id' => { required: true, type: 'Integer' } } } + context 'when route.params and path params use symbol keys' do + let(:path_params) { { id: { required: true, type: 'Integer', format: 'int64' } } } before do allow(route).to receive(:params).and_return( @@ -62,15 +62,15 @@ ) end - it 'looks up path options via param.to_s' do + it 'uses normalized symbol keys for path options' do expect(fulfilled_params).to eq( - id: { required: true, type: 'Integer' } + id: { required: true, type: 'Integer', format: 'int64' } ) end end context 'when route.params uses string keys and path params use symbol keys' do - let(:path_params) { { id: { required: true, type: 'Integer' } } } + let(:path_params) { { id: { required: true, type: 'Integer', format: 'int64' } } } before do allow(route).to receive(:params).and_return( @@ -80,7 +80,7 @@ it 'looks up path options via param.to_sym' do expect(fulfilled_params).to eq( - id: { required: true, type: 'Integer' } + id: { required: true, type: 'Integer', format: 'int64' } ) end end From ce63b05af352ae758b57d004035b69fca4eb69e2 Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:30:11 +1000 Subject: [PATCH 16/20] :recycle: Simplify route matching logic in swagger_routing.rb --- lib/grape-swagger/swagger_routing.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/grape-swagger/swagger_routing.rb b/lib/grape-swagger/swagger_routing.rb index 22c04a622..930482e87 100644 --- a/lib/grape-swagger/swagger_routing.rb +++ b/lib/grape-swagger/swagger_routing.rb @@ -10,9 +10,6 @@ def combine_routes(app, doc_klass) route_match = route_path.split(/^.*?#{route.prefix}/).last next unless route_match - # want to match emojis ... ;) - # route_match = route_match - # .match('\/([\p{Alnum}p{Emoji}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\p{Emoji}\-\_]*)$') route_match = route_match.match('\/([\p{Alnum}\-\_]*?)[\.\/\(]') || route_match.match('\/([\p{Alpha}\-\_]*)$') next unless route_match From 8e89a25cab74205bd028a537db982dd6a5b480c5 Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:31:29 +1000 Subject: [PATCH 17/20] :sparkles: Update grape dependency to require version >= 2.4.0 and adjust compatibility table in README --- README.md | 5 ++--- grape-swagger.gemspec | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e83fd48af..de3c07cfc 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ This screenshot is based on the [Hussars](https://github.com/LeFnord/hussars) sa The following versions of grape, grape-entity and grape-swagger can currently be used together. | grape-swagger | swagger spec | grape | grape-entity | representable | -| --------------------- | ------------ | ----------------------- | ------------ | ------------- | +|-----------------------|--------------|-------------------------|--------------|---------------| | 0.10.5 | 1.2 | >= 0.10.0 ... <= 0.14.0 | < 0.5.0 | n/a | | 0.11.0 | 1.2 | >= 0.16.2 | < 0.5.0 | n/a | | 0.25.2 | 2.0 | >= 0.14.0 ... <= 0.18.0 | <= 0.6.0 | >= 2.4.1 | @@ -123,9 +123,8 @@ The following versions of grape, grape-entity and grape-swagger can currently be | 0.32.0 | 2.0 | >= 0.16.2 | >= 0.5.0 | >= 2.4.1 | | 0.34.0 | 2.0 | >= 0.16.2 ... < 1.3.0 | >= 0.5.0 | >= 2.4.1 | | >= 1.0.0 | 2.0 | >= 1.3.0 | >= 0.5.0 | >= 2.4.1 | -| >= 2.0.0 | 2.0 | >= 1.7.0 | >= 0.5.0 | >= 2.4.1 | | >= 2.0.0 ... <= 2.1.2 | 2.0 | >= 1.8.0 ... < 2.3.0 | >= 0.5.0 | >= 2.4.1 | -| > 2.1.2 | 2.0 | >= 1.8.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | +| > 2.1.2 | 2.0 | >= 2.4.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | ## Swagger-Spec diff --git a/grape-swagger.gemspec b/grape-swagger.gemspec index d88943efa..15ba1aaa0 100644 --- a/grape-swagger.gemspec +++ b/grape-swagger.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |s| s.metadata['rubygems_mfa_required'] = 'true' s.required_ruby_version = '>= 3.1' - s.add_dependency 'grape', '>= 1.7', '< 4.0' + s.add_dependency 'grape', '>= 2.4.0', '< 4.0' s.files = Dir['lib/**/*', '*.md', 'LICENSE.txt', 'grape-swagger.gemspec'] s.require_paths = ['lib'] From 13ad0e16015bfba5386b2f2b8d794089d3a34f39 Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:32:45 +1000 Subject: [PATCH 18/20] :recycle: Remove unused block variable in combine_namespace_routes method --- lib/grape-swagger/swagger_routing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/grape-swagger/swagger_routing.rb b/lib/grape-swagger/swagger_routing.rb index 930482e87..4c90abec9 100644 --- a/lib/grape-swagger/swagger_routing.rb +++ b/lib/grape-swagger/swagger_routing.rb @@ -33,7 +33,7 @@ def determine_namespaced_routes(name, parent_route, routes) def combine_namespace_routes(namespaces, routes) combined_namespace_routes = {} # iterate over each single namespace - namespaces.each_key do |name, _| + namespaces.each_key do |name| # get the parent route for the namespace parent_route_name = extract_parent_route(name) parent_route = routes[parent_route_name] From edb770f846c769b7beaa4ca355066694477e6a2e Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:35:39 +1000 Subject: [PATCH 19/20] :sparkles: Add test for merging namespace options with symbol-keyed route params --- spec/lib/request_param_parsers/route_spec.rb | 32 +++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/spec/lib/request_param_parsers/route_spec.rb b/spec/lib/request_param_parsers/route_spec.rb index 627d2b0f1..ffa6d4e00 100644 --- a/spec/lib/request_param_parsers/route_spec.rb +++ b/spec/lib/request_param_parsers/route_spec.rb @@ -48,37 +48,39 @@ ) end end - end - - describe '#fulfill_params' do - subject(:fulfilled_params) { parser.send(:fulfill_params, path_params) } - context 'when route.params and path params use symbol keys' do - let(:path_params) { { id: { required: true, type: 'Integer', format: 'int64' } } } + context 'when route.params has symbol keys but namespace options use string keys' do + let(:stackable) { Grape::Util::StackableValues.new } + let(:inheritable_setting) { instance_double('inheritable_setting', namespace_stackable: stackable) } + let(:app) { instance_double('app', inheritable_setting:) } before do - allow(route).to receive(:params).and_return( - id: {} - ) + stackable[:namespace] = instance_double('namespace', space: ':id', options: { required: true, type: 'Integer' }) + allow(route).to receive(:app).and_return(app) + allow(route).to receive(:params).and_return(id: {}) end - it 'uses normalized symbol keys for path options' do - expect(fulfilled_params).to eq( - id: { required: true, type: 'Integer', format: 'int64' } + it 'merges namespace options into the symbol-keyed route param' do + expect(parse_request_params).to eq( + id: { required: true, type: 'Integer' } ) end end + end - context 'when route.params uses string keys and path params use symbol keys' do + describe '#fulfill_params' do + subject(:fulfilled_params) { parser.send(:fulfill_params, path_params) } + + context 'when route.params and path params use symbol keys' do let(:path_params) { { id: { required: true, type: 'Integer', format: 'int64' } } } before do allow(route).to receive(:params).and_return( - 'id' => {} + id: {} ) end - it 'looks up path options via param.to_sym' do + it 'uses normalized symbol keys for path options' do expect(fulfilled_params).to eq( id: { required: true, type: 'Integer', format: 'int64' } ) From b37da5afe988d669c58d04883ababa1d2ec73193 Mon Sep 17 00:00:00 2001 From: Niko M Date: Sun, 17 May 2026 15:38:41 +1000 Subject: [PATCH 20/20] :sparkles: Update README to clarify grape dependency version ranges --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de3c07cfc..ce12a1dfc 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ The following versions of grape, grape-entity and grape-swagger can currently be | 0.34.0 | 2.0 | >= 0.16.2 ... < 1.3.0 | >= 0.5.0 | >= 2.4.1 | | >= 1.0.0 | 2.0 | >= 1.3.0 | >= 0.5.0 | >= 2.4.1 | | >= 2.0.0 ... <= 2.1.2 | 2.0 | >= 1.8.0 ... < 2.3.0 | >= 0.5.0 | >= 2.4.1 | -| > 2.1.2 | 2.0 | >= 2.4.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | +| > 2.1.2 ... < 2.2.0 | 2.0 | >= 1.8.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | +| > 2.2.0 | 2.0 | >= 2.4.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | ## Swagger-Spec