From 206cf3430b7d411541b61cac6ff55640c532d0b8 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 17 May 2026 10:38:27 +0200 Subject: [PATCH 01/14] Fix Grape 3.2.1 compatibility: keyword args for desc and custom types Grape 3.2.1 deprecates passing a positional options Hash to `desc` and requires unknown types in params blocks to either be a registered dry-type or implement `parse` (custom type protocol). This change addresses both. - Pass keyword arguments to `desc` in doc_methods.rb so Grape's deprecator does not fire (or raise when behavior = :raise) - Add `self.parse` to the mock `Entities::NestedModule::ApiResponse` so Grape treats it as a custom type instead of attempting a dry-types lookup - Move `type: 'Object'` into the `documentation` hash in params_example_spec so Grape no longer sees an unknown string type; grape-swagger still reads it from the merged settings and emits the expected swagger output --- CHANGELOG.md | 2 +- lib/grape-swagger/doc_methods.rb | 4 ++-- spec/support/model_parsers/mock_parser.rb | 4 +++- spec/swagger_v2/params_example_spec.rb | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428ddb86e..004cc8ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ #### Fixes -* Your contribution here. +* [#977](https://github.com/ruby-grape/grape-swagger/issues/977): Pass keyword arguments to `desc` to fix deprecation warning from Grape - [@numbata](https://github.com/numbata). ### 2.1.4 (2026-02-02) diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index 9e22499d7..dd727a825 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -93,7 +93,7 @@ def setup(options) setup_formatter(options[:format]) - desc api_doc.delete(:desc), api_doc + desc api_doc.delete(:desc), **api_doc instance_eval(guard) unless guard.nil? @@ -105,7 +105,7 @@ def setup(options) .output_path_definitions(target_class.combined_namespace_routes, self, target_class, options) end - desc specific_api_doc.delete(:desc), { params: specific_api_doc.delete(:params) || {}, **specific_api_doc } + desc specific_api_doc.delete(:desc), params: specific_api_doc.delete(:params) || {}, **specific_api_doc params do requires :name, type: String, desc: 'Resource name of mounted API' diff --git a/spec/support/model_parsers/mock_parser.rb b/spec/support/model_parsers/mock_parser.rb index 13bdcc04c..a81d1728e 100644 --- a/spec/support/model_parsers/mock_parser.rb +++ b/spec/support/model_parsers/mock_parser.rb @@ -72,7 +72,9 @@ class RecursiveModel < OpenStruct; end class DocumentedHashAndArrayModel < OpenStruct; end module NestedModule - class ApiResponse < OpenStruct; end + class ApiResponse < OpenStruct + def self.parse(val) = val + end end end end diff --git a/spec/swagger_v2/params_example_spec.rb b/spec/swagger_v2/params_example_spec.rb index fbddf7cca..ebee8efe9 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, documentation: { type: 'Object', example: { 'foo' => 'bar' } } end end From a79917d5518a220580ad3f35d8f8294ad30b9008 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 17 May 2026 11:01:42 +0200 Subject: [PATCH 02/14] Suppress Style/OneClassPerFile for pre-existing top-level modules Moving SwaggerRouting and SwaggerDocumentationAdder inside the GrapeSwagger namespace would be a breaking change for dependent gems that reference these constants by their top-level names. Exclude the affected files in .rubocop_todo.yml to keep CI green until that rename ships separately. --- .rubocop_todo.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 811dd12a2..587c88dc3 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -6,6 +6,14 @@ # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. +# Offense count: 4 +# Configuration parameters: Severity. +Style/OneClassPerFile: + Exclude: + - 'lib/grape-swagger.rb' + - 'spec/support/empty_model_parser.rb' + - 'spec/swagger_v2/guarded_endpoint_spec.rb' + # Offense count: 1 # Configuration parameters: Severity, Include. # Include: **/*.gemspec From ba1228a17511b230e6c41e4ed97d66a11ebbf9c8 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 17 May 2026 13:53:19 +0200 Subject: [PATCH 03/14] Clarify intent of Grape 3.2 compatibility fixes with inline comments Add comments explaining: - 'Object' is a swagger documentation hint, not a valid Grape coercion type; the type lives in documentation: so grape-swagger picks it up via settings merge - ApiResponse.parse is required because Grape 3.2+ validates unknown types via Types.custom? (arity-1 parse check); minimal pass-through is sufficient since tests exercise documentation generation, not request coercion --- spec/support/model_parsers/mock_parser.rb | 4 ++++ spec/swagger_v2/params_example_spec.rb | 3 +++ 2 files changed, 7 insertions(+) diff --git a/spec/support/model_parsers/mock_parser.rb b/spec/support/model_parsers/mock_parser.rb index a81d1728e..de6d24c00 100644 --- a/spec/support/model_parsers/mock_parser.rb +++ b/spec/support/model_parsers/mock_parser.rb @@ -73,6 +73,10 @@ class DocumentedHashAndArrayModel < OpenStruct; end module NestedModule class ApiResponse < OpenStruct + # Grape 3.2+ requires unknown types to implement .parse (arity 1) to pass the + # Types.custom? check and avoid a dry-types lookup that would raise ArgumentError. + # The implementation is minimal because these tests exercise swagger doc generation + # only, not actual request coercion. def self.parse(val) = val end end diff --git a/spec/swagger_v2/params_example_spec.rb b/spec/swagger_v2/params_example_spec.rb index ebee8efe9..f3504e03d 100644 --- a/spec/swagger_v2/params_example_spec.rb +++ b/spec/swagger_v2/params_example_spec.rb @@ -11,6 +11,9 @@ def app params :common_params do requires :id, type: Integer, documentation: { example: 123 } optional :name, type: String, documentation: { example: 'Person' } + # 'Object' is not a valid Grape coercion type; it is a swagger documentation-only hint. + # Grape 3.2+ rejects string type names in params blocks, so the type lives in + # documentation: where grape-swagger picks it up via ParseParams#call settings merge. optional :obj, documentation: { type: 'Object', example: { 'foo' => 'bar' } } end end From d1f07b8b993b291b4a9bb5bd9c648cba7beee71f Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 17 May 2026 14:04:03 +0200 Subject: [PATCH 04/14] Fix Grape 3.2+ compatibility for representable parser's ApiResponse Representable::Decorator does not implement .parse, so Grape 3.2+ raises ArgumentError when ApiResponse is used as a param type. Same fix as mock_parser. --- spec/support/model_parsers/representable_parser.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/support/model_parsers/representable_parser.rb b/spec/support/model_parsers/representable_parser.rb index 2102ce8aa..d56fbd4de 100644 --- a/spec/support/model_parsers/representable_parser.rb +++ b/spec/support/model_parsers/representable_parser.rb @@ -99,6 +99,11 @@ class ApiResponse < Representable::Decorator property :status, documentation: { type: String } property :error, documentation: { type: ::Entities::ApiError } + + # Grape 3.2+ requires unknown types to implement .parse (arity 1) to pass the + # Types.custom? check. Representable::Decorator does not define parse, so we add + # a minimal pass-through sufficient for documentation-generation tests. + def self.parse(val) = val end end From c7458b44b14bd512e3b7666027b568e832e1b251 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Tue, 19 May 2026 01:56:37 +0200 Subject: [PATCH 05/14] Bump minimum Grape dependency to 2.1 and trim CI matrix Grape 1.8.0 and 2.0.0 are broken with Ruby 3.3+ due to a Mustermann private-method incompatibility (named_captures), producing 275 failures on both master and this branch. Supporting these versions is no longer meaningful. Raise the gemspec lower bound from >= 1.7 to >= 2.1 to reflect what actually works, and drop the 1.8.0/2.0.0 rows from the CI matrix so the build result becomes an honest signal. --- .github/workflows/ci.yml | 8 -------- grape-swagger.gemspec | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aef71158..a6ec775c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +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' } - - { 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' } diff --git a/grape-swagger.gemspec b/grape-swagger.gemspec index d88943efa..ce9d6b7fe 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.1', '< 4.0' s.files = Dir['lib/**/*', '*.md', 'LICENSE.txt', 'grape-swagger.gemspec'] s.require_paths = ['lib'] From 54465f4c9c61bbe74ee1545605281203e4e1612e Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Tue, 19 May 2026 02:13:30 +0200 Subject: [PATCH 06/14] Recover actual types from VariantCollectionCoercer in multi-type params In Grape >= 3.3 `type: [A, B]` is wrapped in a VariantCollectionCoercer, and Grape's documentation pipeline serialises that wrapper via #to_s before storing it in route.params, which discards the original type list. The previous behaviour exposed the coercer's #inspect string (e.g. "#") as the swagger type. The live coercer is still reachable via the matching CoerceValidator's @converter, so collect_variant_types walks namespace_stackable[:validations] and builds a name => [types] lookup. When fulfill_params sees the VariantCollectionCoercer signature in the serialised type, it substitutes the real type list and lets the existing Array handling in DataType.parse_multi_type pick the primary type, matching how older Grape versions behaved when they passed the type array through directly. --- .../request_param_parsers/route.rb | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index d3d27d20c..95dc1ffb1 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -19,8 +19,9 @@ def parse stackable_values = route.app&.inheritable_setting&.namespace_stackable path_params = build_path_params(stackable_values) + variant_types = collect_variant_types(stackable_values) - fulfill_params(path_params) + fulfill_params(path_params, variant_types) end private @@ -47,7 +48,33 @@ def fetch_inherited_params(stackable_values) end end - def fulfill_params(path_params) + # In Grape >= 3.3 `type: [A, B]` is wrapped in VariantCollectionCoercer and + # Grape's documentation pipeline serialises it via `#to_s`, so the original + # type list is lost in route.params. The live coercer is still reachable + # through the CoerceValidator's @converter, so we rebuild a name => types + # map and restore it in fulfill_params. + def collect_variant_types(stackable_values) + variant_types = {} + return variant_types unless defined?(Grape::Validations::Types::VariantCollectionCoercer) && + defined?(Grape::Validations::Validators::CoerceValidator) && + stackable_values.respond_to?(:[]) + + Array(stackable_values[:validations]).each do |validator| + next unless validator.is_a?(Grape::Validations::Validators::CoerceValidator) + + converter = validator.instance_variable_get(:@converter) + next unless converter.is_a?(Grape::Validations::Types::VariantCollectionCoercer) + + types = converter.instance_variable_get(:@types) + Array(validator.instance_variable_get(:@attrs)).each do |attr| + variant_types[attr.to_s] = types + end + end + + variant_types + end + + def fulfill_params(path_params, variant_types) # Merge path params options into route params route.params.each_with_object({}) do |(param, definition), accum| # The route.params hash includes both parametrized params (with a string as a key) @@ -57,10 +84,18 @@ def fulfill_params(path_params) next if param.is_a?(String) && accum.key?(key) defined_options = definition.is_a?(Hash) ? definition : {} + defined_options = restore_variant_type(defined_options, param, variant_types) value = (path_params[param] || {}).merge(defined_options) accum[key] = value.empty? ? DEFAULT_PARAM_TYPE : value end end + + def restore_variant_type(defined_options, param, variant_types) + types = variant_types[param.to_s] + return defined_options unless types + + defined_options.merge(type: types) + end end end end From 094216578565a902958d022bae0f620459a2f096 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Tue, 19 May 2026 02:58:12 +0200 Subject: [PATCH 07/14] Address review findings: scope-aware variant keys, robust desc kwargs VariantCollectionCoercer recovery (route.rb) - Build the variant-type map keyed by the fully-qualified param name (e.g. "group[inner]") via the validator's @scope.full_name(attr), so nested-param multi-types are restored instead of being looked up under the wrong key, and same-named outer params at a different scope are not clobbered. - Use the public validator.attrs reader, and normalise the recovered @types to an Array so an order-defined coercer Set cannot leak in. - Cover the inheritance shape of stackable[:validations] with .flatten. desc keyword-args (doc_methods.rb) - Coerce api_documentation / specific_api_documentation keys to symbols via transform_keys before splatting, so string-keyed Hashes (YAML/JSON configs) no longer raise TypeError under `**`. - Accept :description as an alias for :desc. Without this, a user-supplied :description was forwarded into Grape's desc and immediately overwritten with nil because the positional description arg was missing. Tests - Add a regression spec asserting `type: [Integer, Float]` produces "integer" (the only assertion that proves the recovery actually wires the real types through; previously [String, Integer] also "worked" via the broken fallback because String happens to be first). - Cover the nested-namespace case (`group { requires :inner, type: [...] }`) to lock in the scope-aware key. CHANGELOG - Document the >= 2.1 Grape floor bump and the `type: 'Object'` migration pattern under a Breaking changes section, and split the multi-type recovery and desc-kwargs robustness fixes into separate entries. --- CHANGELOG.md | 7 +++++ lib/grape-swagger/doc_methods.rb | 12 +++++--- .../request_param_parsers/route.rb | 12 ++++---- spec/swagger_v2/param_multi_type_spec.rb | 29 +++++++++++++++++++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 004cc8ba7..a8690e0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,16 @@ * Your contribution here. +#### Breaking changes + +* Minimum required Grape version is now `>= 2.1` (was `>= 1.7`). Grape 1.8.0 and 2.0.0 do not work on Ruby 3.3+ due to a Mustermann private-method incompatibility (the existing CI rows for those versions had been failing on master). Their CI rows are removed. +* On Grape 3.2+ the `params` block rejects string type names. If you used `params { optional :foo, type: 'Object' }` to declare a swagger-only documentation hint, move the type into the `documentation:` hash: `optional :foo, documentation: { type: 'Object' }`. grape-swagger picks the type up from the merged settings unchanged. + #### Fixes * [#977](https://github.com/ruby-grape/grape-swagger/issues/977): Pass keyword arguments to `desc` to fix deprecation warning from Grape - [@numbata](https://github.com/numbata). +* Accept string-keyed `api_documentation` / `specific_api_documentation` and the `:description` key as an alias for `:desc`, matching the pre-keyword-args behaviour of `desc`. +* Grape 3.3+: recover the real type list for multi-type params (`type: [A, B]`). Grape now wraps these in a `VariantCollectionCoercer` and serialises it via `#to_s` in `route.params`, which lost the original types. Swagger output now reflects the first declared type (e.g. `type: [Integer, Float]` produces `"integer"`) instead of the coercer's `#inspect` string. ### 2.1.4 (2026-02-02) diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index dd727a825..02b3f7b4d 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -86,14 +86,14 @@ def setup(options) # for available options see #defaults target_class = options[:target_class] guard = options[:swagger_endpoint_guard] - api_doc = options[:api_documentation].dup - specific_api_doc = options[:specific_api_documentation].dup + api_doc = options[:api_documentation].transform_keys(&:to_sym) + specific_api_doc = options[:specific_api_documentation].transform_keys(&:to_sym) class_variables_from(options) setup_formatter(options[:format]) - desc api_doc.delete(:desc), **api_doc + desc(pop_desc(api_doc), **api_doc) instance_eval(guard) unless guard.nil? @@ -105,7 +105,7 @@ def setup(options) .output_path_definitions(target_class.combined_namespace_routes, self, target_class, options) end - desc specific_api_doc.delete(:desc), params: specific_api_doc.delete(:params) || {}, **specific_api_doc + desc(pop_desc(specific_api_doc), params: specific_api_doc.delete(:params) || {}, **specific_api_doc) params do requires :name, type: String, desc: 'Resource name of mounted API' @@ -136,5 +136,9 @@ def setup_formatter(formatter) FORMATTER_METHOD.each { |method| send(method, formatter) } end + + def pop_desc(doc) + doc.delete(:desc) || doc.delete(:description) + end end end diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index 95dc1ffb1..ba8e88b14 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -52,22 +52,24 @@ def fetch_inherited_params(stackable_values) # Grape's documentation pipeline serialises it via `#to_s`, so the original # type list is lost in route.params. The live coercer is still reachable # through the CoerceValidator's @converter, so we rebuild a name => types - # map and restore it in fulfill_params. + # map keyed by the fully-qualified param name (e.g. "group[inner]") to match + # route.params keys and avoid clobbering same-named params at outer scopes. def collect_variant_types(stackable_values) variant_types = {} return variant_types unless defined?(Grape::Validations::Types::VariantCollectionCoercer) && defined?(Grape::Validations::Validators::CoerceValidator) && stackable_values.respond_to?(:[]) - Array(stackable_values[:validations]).each do |validator| + Array(stackable_values[:validations]).flatten.each do |validator| next unless validator.is_a?(Grape::Validations::Validators::CoerceValidator) converter = validator.instance_variable_get(:@converter) next unless converter.is_a?(Grape::Validations::Types::VariantCollectionCoercer) - types = converter.instance_variable_get(:@types) - Array(validator.instance_variable_get(:@attrs)).each do |attr| - variant_types[attr.to_s] = types + types = Array(converter.instance_variable_get(:@types)) + scope = validator.instance_variable_get(:@scope) + validator.attrs.each do |attr| + variant_types[scope.full_name(attr)] = types end end diff --git a/spec/swagger_v2/param_multi_type_spec.rb b/spec/swagger_v2/param_multi_type_spec.rb index afcc10b0a..89d9486c1 100644 --- a/spec/swagger_v2/param_multi_type_spec.rb +++ b/spec/swagger_v2/param_multi_type_spec.rb @@ -50,6 +50,35 @@ def app ] end + describe 'with non-string primary type' do + def app + Class.new(Grape::API) do + format :json + + desc 'action' do + consumes ['application/x-www-form-urlencoded'] + end + params do + requires :int_first, type: [Integer, Float] + requires :nested_group, type: Hash do + requires :nested_int, type: [Integer, Float] + end + end + post :action do + { message: 'hi' } + end + + add_swagger_documentation + end + end + + it 'recovers the first variant type, not a hardcoded fallback' do + types = subject.to_h { |p| [p['name'], p['type']] } + expect(types['int_first']).to eq('integer') + expect(types['nested_group[nested_int]']).to eq('integer') + end + end + describe 'header params' do def app Class.new(Grape::API) do From 64ebd4cc0e1c9f0042e392abdbdbac13b487320a Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Tue, 19 May 2026 07:55:43 +0200 Subject: [PATCH 08/14] Tighten variant-type order, pop_desc nil semantics, document recovery scope route.rb - Convert the recovered VariantCollectionCoercer @types via #to_a so a Set-backed coercer yields a deterministic order (insertion order from the user's declaration), matching what `parse_multi_type` will pick via `.first` downstream. - Guard scope.full_name with `respond_to?` so a future CoerceValidator built without @scope (custom subclasses, test doubles) degrades to skipping that validator rather than crashing the whole swagger doc generation with NoMethodError. - Expand the leading comment to note that on Grape < 3.3 the recovery is a no-op (the legacy `"[A, B]"` string is already handled by parse_multi_type's regex branch), so a reader doesn't conclude the defined? guard is silently overriding working behaviour. doc_methods.rb (pop_desc) - Use `key?` instead of `||` so an explicit `desc: nil` is respected rather than silently falling through to `:description`. Document the precedence inline (`:desc` wins when both keys are supplied). specs - Cover the new public-facing `:desc`/`:description`/string-key semantics in api_documentation_spec so a future refactor can't regress them silently. --- lib/grape-swagger/doc_methods.rb | 4 ++- .../request_param_parsers/route.rb | 22 ++++++++---- spec/swagger_v2/api_documentation_spec.rb | 36 +++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index 02b3f7b4d..5d72f745f 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -138,7 +138,9 @@ def setup_formatter(formatter) end def pop_desc(doc) - doc.delete(:desc) || doc.delete(:description) + # :desc takes precedence over :description; explicit nil under :desc wins + # (don't fall through on nil — that would silently substitute :description). + doc.key?(:desc) ? doc.delete(:desc) : doc.delete(:description) end end end diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index ba8e88b14..1539da543 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -48,12 +48,16 @@ def fetch_inherited_params(stackable_values) end end - # In Grape >= 3.3 `type: [A, B]` is wrapped in VariantCollectionCoercer and - # Grape's documentation pipeline serialises it via `#to_s`, so the original - # type list is lost in route.params. The live coercer is still reachable - # through the CoerceValidator's @converter, so we rebuild a name => types - # map keyed by the fully-qualified param name (e.g. "group[inner]") to match - # route.params keys and avoid clobbering same-named params at outer scopes. + # On Grape >= 3.3 `type: [A, B]` is documented in route.params via the + # VariantCollectionCoercer's `#to_s`, which loses the original type list. + # On earlier versions the same param appears as the stringified Array + # `"[A, B]"` and the existing regex in DataType.parse_multi_type already + # handles it; the recovery here is a no-op for those versions. + # + # The live coercer is still reachable through the CoerceValidator's + # @converter, so we rebuild a name => types map keyed by the fully-qualified + # param name (e.g. "group[inner]") to match route.params keys and avoid + # clobbering same-named params at outer scopes. def collect_variant_types(stackable_values) variant_types = {} return variant_types unless defined?(Grape::Validations::Types::VariantCollectionCoercer) && @@ -66,8 +70,12 @@ def collect_variant_types(stackable_values) converter = validator.instance_variable_get(:@converter) next unless converter.is_a?(Grape::Validations::Types::VariantCollectionCoercer) - types = Array(converter.instance_variable_get(:@types)) + # `.to_a` preserves the user-declared order for both Array and Set + # storage shapes; `DataType.parse_multi_type` uses `.first` downstream. + types = converter.instance_variable_get(:@types).to_a scope = validator.instance_variable_get(:@scope) + next unless scope.respond_to?(:full_name) + validator.attrs.each do |attr| variant_types[scope.full_name(attr)] = types end diff --git a/spec/swagger_v2/api_documentation_spec.rb b/spec/swagger_v2/api_documentation_spec.rb index cd0062a96..044d7a57b 100644 --- a/spec/swagger_v2/api_documentation_spec.rb +++ b/spec/swagger_v2/api_documentation_spec.rb @@ -23,4 +23,40 @@ 'Swagger compatible API description for specific API' ] end + + context 'when api_documentation is string-keyed' do + let(:api) do + Class.new(Grape::API) do + add_swagger_documentation api_documentation: { 'desc' => 'String-keyed description' } + end + end + + it 'accepts string keys' do + expect(subject.first[:description]).to eq('String-keyed description') + end + end + + context 'when api_documentation uses :description instead of :desc' do + let(:api) do + Class.new(Grape::API) do + add_swagger_documentation api_documentation: { description: 'Via description key' } + end + end + + it 'falls back to :description' do + expect(subject.first[:description]).to eq('Via description key') + end + end + + context 'when both :desc and :description are supplied' do + let(:api) do + Class.new(Grape::API) do + add_swagger_documentation api_documentation: { desc: 'Desc wins', description: 'Description loses' } + end + end + + it ':desc takes precedence' do + expect(subject.first[:description]).to eq('Desc wins') + end + end end From 2457d42d0edd9c43bf79b1e62da8db7af4da6c22 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Tue, 19 May 2026 23:11:20 +0200 Subject: [PATCH 09/14] Bump to 2.2.0; document breaking changes and tighten review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer feedback on PR #978, this release carries breaking changes (Grape floor bump, `type: 'Object'` migration, custom-type `parse` requirement) and therefore lands as 2.2.0, with a matching UPGRADING entry and README compatibility-matrix row. Docs - README: add a `>= 2.2.0` compatibility row and cap the prior row at `< 2.2.0`. Document the new `:description` alias / string-keyed `api_documentation` affordance and the `:desc` precedence rule. - UPGRADING: new "Upgrading to >= 2.2.0" section covering all three user-facing breaking changes (Grape floor, `type: 'Object'`, custom types needing `.parse`). - CHANGELOG: split bullets into Breaking changes / Features / Fixes with accurate wording. Drop the misleading "matching the pre-keyword -args behaviour" framing — `:description` and string keys are new affordances. Add the custom-type `parse` requirement. Note that the variant-type recovery is a no-op on Grape < 3.3. Code - `pop_desc` is now private; it was an internal helper to `setup` but was leaking onto every `Grape::API` that mixed in `DocMethods`. - `route.rb`: `flatten(1)` instead of unbounded `.flatten`, and skip the merge when the recovered coercer `@types` is empty so we don't overwrite the legacy serialisation with `[]`. Tests - Add regression coverage for the parts of `pop_desc` that the v2 fix was actually for: explicit `desc: nil` must not fall through to `:description`, and `specific_api_documentation: { description: ... }` goes through the second `pop_desc` call site too. Public-API audit: `@converter` / `@types` (on VariantCollectionCoercer) and `@scope` (on Validators::Base) have no public reader on the supported Grape range (`scope` is private on HEAD and absent on 3.2.1), so the `instance_variable_get` reaches remain — there is no stable public alternative across the matrix. --- CHANGELOG.md | 18 ++++++------- README.md | 5 +++- UPGRADING.md | 21 +++++++++++++++ lib/grape-swagger/doc_methods.rb | 6 +++-- .../request_param_parsers/route.rb | 7 ++++- lib/grape-swagger/version.rb | 2 +- spec/swagger_v2/api_documentation_spec.rb | 26 ++++++++++++++++++- 7 files changed, 70 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8690e0ba..3d6f4cdcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,19 @@ -### 2.1.5 (Next) +### 2.2.0 (Next) -#### Features +#### Breaking changes -* Your contribution here. +* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Bump minimum Grape to `>= 2.1` (was `>= 1.7`); drop Grape 1.8/2.0 CI rows. See [UPGRADING](UPGRADING.md) - [@numbata](https://github.com/numbata). +* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): On Grape 3.2+, declare swagger-only types under `documentation: { type: 'Object' }` instead of `params { type: 'Object' }` - [@numbata](https://github.com/numbata). +* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): On Grape 3.2+, custom type classes used via `type: MyClass` must implement `MyClass.parse(value)` - [@numbata](https://github.com/numbata). -#### Breaking changes +#### Features -* Minimum required Grape version is now `>= 2.1` (was `>= 1.7`). Grape 1.8.0 and 2.0.0 do not work on Ruby 3.3+ due to a Mustermann private-method incompatibility (the existing CI rows for those versions had been failing on master). Their CI rows are removed. -* On Grape 3.2+ the `params` block rejects string type names. If you used `params { optional :foo, type: 'Object' }` to declare a swagger-only documentation hint, move the type into the `documentation:` hash: `optional :foo, documentation: { type: 'Object' }`. grape-swagger picks the type up from the merged settings unchanged. +* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Accept string-keyed `api_documentation` / `specific_api_documentation` and a `:description` alias for `:desc` - [@numbata](https://github.com/numbata). #### Fixes -* [#977](https://github.com/ruby-grape/grape-swagger/issues/977): Pass keyword arguments to `desc` to fix deprecation warning from Grape - [@numbata](https://github.com/numbata). -* Accept string-keyed `api_documentation` / `specific_api_documentation` and the `:description` key as an alias for `:desc`, matching the pre-keyword-args behaviour of `desc`. -* Grape 3.3+: recover the real type list for multi-type params (`type: [A, B]`). Grape now wraps these in a `VariantCollectionCoercer` and serialises it via `#to_s` in `route.params`, which lost the original types. Swagger output now reflects the first declared type (e.g. `type: [Integer, Float]` produces `"integer"`) instead of the coercer's `#inspect` string. +* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Pass keyword arguments to `desc` to fix Grape 3.2 deprecation - [@numbata](https://github.com/numbata). +* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): On Grape 3.3+, recover multi-type params (`type: [A, B]`) from `VariantCollectionCoercer` so swagger reflects the first declared type instead of the coercer's `#inspect` string - [@numbata](https://github.com/numbata). ### 2.1.4 (2026-02-02) diff --git a/README.md b/README.md index e83fd48af..466c09c12 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ The following versions of grape, grape-entity and grape-swagger can currently be | >= 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.3 ... < 2.2.0 | 2.0 | >= 1.8.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | +| >= 2.2.0 | 2.0 | >= 2.1 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | ## Swagger-Spec @@ -498,6 +499,8 @@ add_swagger_documentation \ api_documentation: { desc: 'Reticulated splines API swagger-compatible documentation.' } ``` +`:description` is accepted as an alias for `:desc` (when both are supplied, `:desc` wins; an explicit `desc: nil` is respected and does not fall through). String keys (e.g. when loading from YAML/JSON) are accepted too. + #### specific_api_documentation Customize the Swagger API specific documentation route, typically contains a `desc` field. The default description is "Swagger compatible API description for specific API". diff --git a/UPGRADING.md b/UPGRADING.md index 1df6264cd..9b620f0e8 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,26 @@ ## Upgrading Grape-swagger +### Upgrading to >= 2.2.0 + +- **Minimum Grape version is now `>= 2.1`** (was `>= 1.7`). Grape 1.8.0 and 2.0.0 cannot be used on Ruby 3.3+ because of an upstream Mustermann/forwardable incompatibility; the CI rows for those combinations were already failing on `master` and have been removed. +- **`type: 'Object'` (and other string type names) in `params` blocks**: Grape 3.2+ rejects string type names. If you previously declared a swagger-only documentation hint via `params { optional :foo, type: 'Object' }`, move the type under `documentation:`: + + ```ruby + optional :foo, documentation: { type: 'Object' } + ``` + + grape-swagger picks the type up from the merged settings unchanged, so the swagger output is identical. +- **Custom type classes** used via `type: MyClass` must implement `MyClass.parse(value)` (arity 1) on Grape 3.2+; otherwise Grape's dry-types lookup raises `ArgumentError`. `Grape::Entity` already provides `parse`; `Representable::Decorator` and plain Ruby classes need to define it explicitly: + + ```ruby + class MyType + def self.parse(value) = new(value) + # ... + end + ``` + +- **Multi-type params (`type: [A, B]`) on Grape 3.3+**: swagger output now reflects the first declared type (e.g. `type: [Integer, Float]` produces `"integer"`). Previously, Grape 3.3 serialized the `VariantCollectionCoercer` wrapper via `#to_s`, leaking `"#"` into the documentation. No action required, but if you were programmatically post-processing that string, the fix will change the output. + ### Upgrading to >= x.y.z - Grape-swagger now documents array parameters within an object schema in Swagger. This aligns with grape's JSON structure requirements and ensures the documentation is correct. diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index 5d72f745f..b78f614e0 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -137,9 +137,11 @@ def setup_formatter(formatter) FORMATTER_METHOD.each { |method| send(method, formatter) } end + private + + # :desc takes precedence over :description; explicit nil under :desc wins + # (don't fall through on nil — that would silently substitute :description). def pop_desc(doc) - # :desc takes precedence over :description; explicit nil under :desc wins - # (don't fall through on nil — that would silently substitute :description). doc.key?(:desc) ? doc.delete(:desc) : doc.delete(:description) end end diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index 1539da543..b417f9009 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -64,7 +64,10 @@ def collect_variant_types(stackable_values) defined?(Grape::Validations::Validators::CoerceValidator) && stackable_values.respond_to?(:[]) - Array(stackable_values[:validations]).flatten.each do |validator| + # `flatten(1)` only collapses the inheritance-stack levels (StackableValues + # stores `[[...level1...], [...level2...]]`); avoids descending into + # validator internals if any ever implements `to_ary`. + Array(stackable_values[:validations]).flatten(1).each do |validator| next unless validator.is_a?(Grape::Validations::Validators::CoerceValidator) converter = validator.instance_variable_get(:@converter) @@ -73,6 +76,8 @@ def collect_variant_types(stackable_values) # `.to_a` preserves the user-declared order for both Array and Set # storage shapes; `DataType.parse_multi_type` uses `.first` downstream. types = converter.instance_variable_get(:@types).to_a + next if types.empty? + scope = validator.instance_variable_get(:@scope) next unless scope.respond_to?(:full_name) diff --git a/lib/grape-swagger/version.rb b/lib/grape-swagger/version.rb index 24e1e6d97..cb9a6be28 100644 --- a/lib/grape-swagger/version.rb +++ b/lib/grape-swagger/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module GrapeSwagger - VERSION = '2.1.5' + VERSION = '2.2.0' end diff --git a/spec/swagger_v2/api_documentation_spec.rb b/spec/swagger_v2/api_documentation_spec.rb index 044d7a57b..34f3fc01f 100644 --- a/spec/swagger_v2/api_documentation_spec.rb +++ b/spec/swagger_v2/api_documentation_spec.rb @@ -56,7 +56,31 @@ end it ':desc takes precedence' do - expect(subject.first[:description]).to eq('Desc wins') + expect(subject.pluck(:description)).to include('Desc wins') + end + end + + context 'when :desc is explicitly nil' do + let(:api) do + Class.new(Grape::API) do + add_swagger_documentation api_documentation: { desc: nil, description: 'fallback' } + end + end + + it 'respects the explicit nil and does not fall through to :description' do + expect(subject.pluck(:description)).not_to include('fallback') + end + end + + context 'when specific_api_documentation uses :description' do + let(:api) do + Class.new(Grape::API) do + add_swagger_documentation specific_api_documentation: { description: 'Specific via description' } + end + end + + it 'accepts :description on the specific endpoint too' do + expect(subject.pluck(:description)).to include('Specific via description') end end end From 5bda1680caad88ed0520820aaa38f05f11631ff0 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 1 Jun 2026 00:40:36 +0200 Subject: [PATCH 10/14] Simplify collect_variant_types and split dense desc call StackableValues#[] already returns a flat Array, so the Array(...).flatten(1) wrapping in collect_variant_types was redundant noise. Replace it with a direct .each and update the comment to match reality. Add an explicit failure-mode note: if Grape ever renames the private ivars (@converter, @types, @scope), the method silently returns {} and swagger degrades to the pre-fix broken output rather than crashing. Split the three-operation desc(pop_desc(...), params: ..., **...) one-liner into named locals for readability. --- lib/grape-swagger/doc_methods.rb | 4 +++- lib/grape-swagger/request_param_parsers/route.rb | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index b78f614e0..8cbc74286 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -105,7 +105,9 @@ def setup(options) .output_path_definitions(target_class.combined_namespace_routes, self, target_class, options) end - desc(pop_desc(specific_api_doc), params: specific_api_doc.delete(:params) || {}, **specific_api_doc) + specific_desc = pop_desc(specific_api_doc) + specific_params = specific_api_doc.delete(:params) || {} + desc(specific_desc, params: specific_params, **specific_api_doc) params do requires :name, type: String, desc: 'Resource name of mounted API' diff --git a/lib/grape-swagger/request_param_parsers/route.rb b/lib/grape-swagger/request_param_parsers/route.rb index b417f9009..2ffa9ee92 100644 --- a/lib/grape-swagger/request_param_parsers/route.rb +++ b/lib/grape-swagger/request_param_parsers/route.rb @@ -58,16 +58,19 @@ def fetch_inherited_params(stackable_values) # @converter, so we rebuild a name => types map keyed by the fully-qualified # param name (e.g. "group[inner]") to match route.params keys and avoid # clobbering same-named params at outer scopes. + # + # NOTE: @converter, @types, and @scope are private Grape ivars. If Grape + # renames them, this method silently returns {} and swagger falls back to + # the pre-fix broken output (coercer inspect string) — not a crash. def collect_variant_types(stackable_values) variant_types = {} return variant_types unless defined?(Grape::Validations::Types::VariantCollectionCoercer) && defined?(Grape::Validations::Validators::CoerceValidator) && stackable_values.respond_to?(:[]) - # `flatten(1)` only collapses the inheritance-stack levels (StackableValues - # stores `[[...level1...], [...level2...]]`); avoids descending into - # validator internals if any ever implements `to_ary`. - Array(stackable_values[:validations]).flatten(1).each do |validator| + # StackableValues#[] concatenates inheritance levels and always returns + # a flat Array of validator instances — no wrapping or flattening needed. + stackable_values[:validations].each do |validator| next unless validator.is_a?(Grape::Validations::Validators::CoerceValidator) converter = validator.instance_variable_get(:@converter) From 890dac041ddf4fa855eb2e1040da732fea39e091 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 1 Jun 2026 00:44:57 +0200 Subject: [PATCH 11/14] Bump Metrics/MethodLength todo max to 30 after setup split --- .rubocop_todo.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 587c88dc3..2caee130d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -29,7 +29,7 @@ Metrics/AbcSize: # Offense count: 32 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 28 + Max: 30 # Offense count: 9 # Configuration parameters: AllowedMethods, AllowedPatterns. From ef824322e9115c5ddfc850d4ac952580214b082a Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 1 Jun 2026 00:46:05 +0200 Subject: [PATCH 12/14] To make Danger happy --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6f4cdcb..d74215d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,18 @@ * [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Bump minimum Grape to `>= 2.1` (was `>= 1.7`); drop Grape 1.8/2.0 CI rows. See [UPGRADING](UPGRADING.md) - [@numbata](https://github.com/numbata). * [#978](https://github.com/ruby-grape/grape-swagger/pull/978): On Grape 3.2+, declare swagger-only types under `documentation: { type: 'Object' }` instead of `params { type: 'Object' }` - [@numbata](https://github.com/numbata). * [#978](https://github.com/ruby-grape/grape-swagger/pull/978): On Grape 3.2+, custom type classes used via `type: MyClass` must implement `MyClass.parse(value)` - [@numbata](https://github.com/numbata). +* Your contribution here. #### Features * [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Accept string-keyed `api_documentation` / `specific_api_documentation` and a `:description` alias for `:desc` - [@numbata](https://github.com/numbata). +* Your contribution here. #### Fixes * [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Pass keyword arguments to `desc` to fix Grape 3.2 deprecation - [@numbata](https://github.com/numbata). * [#978](https://github.com/ruby-grape/grape-swagger/pull/978): On Grape 3.3+, recover multi-type params (`type: [A, B]`) from `VariantCollectionCoercer` so swagger reflects the first declared type instead of the coercer's `#inspect` string - [@numbata](https://github.com/numbata). +* Your contribution here. ### 2.1.4 (2026-02-02) From d08f8ab04d3494dc5281594d66b5402a4205976a Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 1 Jun 2026 06:00:57 +0200 Subject: [PATCH 13/14] Drop ruby 3.2 + grape HEAD matrix row; grape HEAD now requires Ruby >= 3.3 --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6ec775c6..b9b7247dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,6 @@ jobs: - { ruby: '3.3', grape: '2.2.0' } - { ruby: '3.4', grape: '2.2.0' } - { ruby: 'head', grape: '2.2.0' } - - { ruby: '3.2', grape: 'HEAD' } - { ruby: '3.3', grape: 'HEAD' } - { ruby: '3.4', grape: 'HEAD' } name: test (ruby=${{ matrix.entry.ruby }}, grape=${{ matrix.entry.grape }}) From a6055027a8056c4a3284ae88ae89c40c0c126561 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 1 Jun 2026 07:22:25 +0200 Subject: [PATCH 14/14] Pass for: to Grape::Endpoint.new for Grape HEAD compatibility Grape HEAD introduced Grape::Endpoint::Options (a Data class) that requires a :for keyword (the owner API class). Constructing an Endpoint without it raises ArgumentError: missing keyword: :for. --- spec/lib/endpoint_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/lib/endpoint_spec.rb b/spec/lib/endpoint_spec.rb index ba6724488..6e3884941 100644 --- a/spec/lib/endpoint_spec.rb +++ b/spec/lib/endpoint_spec.rb @@ -4,7 +4,8 @@ describe Grape::Endpoint do subject do - described_class.new(Grape::Util::InheritableSetting.new, path: '/', method: :get) + # Grape >= HEAD requires :for (the owner API class) when constructing an Endpoint. + described_class.new(Grape::Util::InheritableSetting.new, path: '/', method: :get, for: Class.new(Grape::API)) end describe '.content_types_for' do