diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2aef71158..b9b7247dc 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' }
@@ -43,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 }})
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 811dd12a2..2caee130d 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
@@ -21,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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 428ddb86e..d74215d6b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,21 @@
-### 2.1.5 (Next)
+### 2.2.0 (Next)
+
+#### Breaking changes
+
+* [#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)
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/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']
diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb
index 9e22499d7..8cbc74286 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,9 @@ 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 }
+ 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'
@@ -136,5 +138,13 @@ 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)
+ 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 d3d27d20c..2ffa9ee92 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,51 @@ def fetch_inherited_params(stackable_values)
end
end
- def fulfill_params(path_params)
+ # 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.
+ #
+ # 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?(:[])
+
+ # 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)
+ next unless converter.is_a?(Grape::Validations::Types::VariantCollectionCoercer)
+
+ # `.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)
+
+ validator.attrs.each do |attr|
+ variant_types[scope.full_name(attr)] = 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 +102,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
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/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
diff --git a/spec/support/model_parsers/mock_parser.rb b/spec/support/model_parsers/mock_parser.rb
index 13bdcc04c..de6d24c00 100644
--- a/spec/support/model_parsers/mock_parser.rb
+++ b/spec/support/model_parsers/mock_parser.rb
@@ -72,7 +72,13 @@ class RecursiveModel < OpenStruct; end
class DocumentedHashAndArrayModel < OpenStruct; end
module NestedModule
- class ApiResponse < OpenStruct; end
+ 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
end
end
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
diff --git a/spec/swagger_v2/api_documentation_spec.rb b/spec/swagger_v2/api_documentation_spec.rb
index cd0062a96..34f3fc01f 100644
--- a/spec/swagger_v2/api_documentation_spec.rb
+++ b/spec/swagger_v2/api_documentation_spec.rb
@@ -23,4 +23,64 @@
'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.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
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
diff --git a/spec/swagger_v2/params_example_spec.rb b/spec/swagger_v2/params_example_spec.rb
index fbddf7cca..f3504e03d 100644
--- a/spec/swagger_v2/params_example_spec.rb
+++ b/spec/swagger_v2/params_example_spec.rb
@@ -11,7 +11,10 @@ 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' } }
+ # '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