Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand All @@ -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 }})
Expand Down
10 changes: 9 additions & 1 deletion .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a name="swagger-spec"></a>
Expand Down Expand Up @@ -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".
Expand Down
21 changes: 21 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -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 `"#<Grape::Validations::Types::VariantCollectionCoercer:0x...>"` 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.
Expand Down
2 changes: 1 addition & 1 deletion grape-swagger.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
18 changes: 14 additions & 4 deletions lib/grape-swagger/doc_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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'
Expand Down Expand Up @@ -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
57 changes: 55 additions & 2 deletions lib/grape-swagger/request_param_parsers/route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
2 changes: 1 addition & 1 deletion lib/grape-swagger/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module GrapeSwagger
VERSION = '2.1.5'
VERSION = '2.2.0'
end
3 changes: 2 additions & 1 deletion spec/lib/endpoint_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion spec/support/model_parsers/mock_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions spec/support/model_parsers/representable_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions spec/swagger_v2/api_documentation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions spec/swagger_v2/param_multi_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading