Skip to content

Commit 6115cd3

Browse files
authored
Fix Grape 3.2+ compatibility: desc kwargs, custom types, multi-type param recovery (#978)
Grape 3.2+ introduced breaking changes that raise when Grape.deprecator.behavior = :raise: desc rejects a positional options Hash, params blocks reject string type names and classes without .parse, and type: [A, B] params are serialised via VariantCollectionCoercer#to_s, leaking the coercer's inspect string into swagger output instead of the declared type. - Pass keyword arguments to both desc calls in doc_methods.rb. Accept string-keyed api_documentation hashes and :description as an alias for :desc. - Add .parse shims to NestedModule::ApiResponse in the mock and representable parsers. Move type: 'Object' in params_example_spec into documentation: {}. - Recover the actual type list from VariantCollectionCoercer via stackable_values[:validations] in the Route param parser. Handles both the Hash-entry shape (Grape 3.2+) and the CoerceValidator object shape (older versions). Canary tests guard the private-ivar contract. - Bump minimum Grape to >= 2.1 (1.8/2.0 fail on Ruby 3.3+ upstream) and expand upper bound to < 5.0 for Grape 4.x. Update CI matrix accordingly.
1 parent 018faba commit 6115cd3

18 files changed

Lines changed: 390 additions & 39 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ jobs:
2626
strategy:
2727
matrix:
2828
entry:
29-
- { ruby: '3.1', grape: '1.8.0' }
30-
- { ruby: '3.2', grape: '1.8.0' }
31-
- { ruby: '3.3', grape: '1.8.0' }
32-
- { ruby: '3.4', grape: '1.8.0' }
33-
- { ruby: '3.1', grape: '2.0.0' }
34-
- { ruby: '3.2', grape: '2.0.0' }
35-
- { ruby: '3.3', grape: '2.0.0' }
36-
- { ruby: '3.4', grape: '2.0.0' }
3729
- { ruby: '3.1', grape: '2.1.3' }
3830
- { ruby: '3.2', grape: '2.1.3' }
3931
- { ruby: '3.3', grape: '2.1.3' }
@@ -43,7 +35,9 @@ jobs:
4335
- { ruby: '3.3', grape: '2.2.0' }
4436
- { ruby: '3.4', grape: '2.2.0' }
4537
- { ruby: 'head', grape: '2.2.0' }
46-
- { ruby: '3.2', grape: 'HEAD' }
38+
- { ruby: '3.2', grape: '3.2.1' }
39+
- { ruby: '3.3', grape: '3.2.1' }
40+
- { ruby: '3.4', grape: '3.2.1' }
4741
- { ruby: '3.3', grape: 'HEAD' }
4842
- { ruby: '3.4', grape: 'HEAD' }
4943
name: test (ruby=${{ matrix.entry.ruby }}, grape=${{ matrix.entry.grape }})

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,8 @@ Style/RedundantArrayConstructor:
7373
Style/RegexpLiteral:
7474
Enabled: false
7575

76+
Style/OneClassPerFile:
77+
Enabled: false
78+
7679
Style/SlicingWithRange:
7780
Enabled: false

.rubocop_todo.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Metrics/AbcSize:
2121
# Offense count: 32
2222
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
2323
Metrics/MethodLength:
24-
Max: 28
24+
Max: 30
2525

2626
# Offense count: 9
2727
# Configuration parameters: AllowedMethods, AllowedPatterns.

CHANGELOG.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
### 2.1.5 (Next)
2-
3-
#### Features
4-
5-
* Your contribution here.
6-
7-
#### Fixes
1+
### 2.2.0 (Next)
82

3+
* [#978](https://github.com/ruby-grape/grape-swagger/pull/978): Fix Grape 3.2+ compatibility: desc kwargs, custom types, multi-type param recovery; bump Grape to `>= 2.1, < 5.0`. See [UPGRADING](UPGRADING.md) - [@numbata](https://github.com/numbata).
94
* Your contribution here.
105

116
### 2.1.4 (2026-02-02)

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ group :development, :test do
2929
end
3030

3131
gem 'cgi'
32+
gem 'multi_json'
3233
gem 'rack-cors'
3334
gem 'rack-test'
3435
gem 'rake'

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ The following versions of grape, grape-entity and grape-swagger can currently be
125125
| >= 1.0.0 | 2.0 | >= 1.3.0 | >= 0.5.0 | >= 2.4.1 |
126126
| >= 2.0.0 | 2.0 | >= 1.7.0 | >= 0.5.0 | >= 2.4.1 |
127127
| >= 2.0.0 ... <= 2.1.2 | 2.0 | >= 1.8.0 ... < 2.3.0 | >= 0.5.0 | >= 2.4.1 |
128-
| > 2.1.2 | 2.0 | >= 1.8.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 |
128+
| >= 2.1.3 ... < 2.2.0 | 2.0 | >= 1.8.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 |
129+
| >= 2.2.0 | 2.0 | >= 2.1 ... < 5.0 | >= 0.5.0 | >= 2.4.1 |
129130

130131

131132
## Swagger-Spec <a name="swagger-spec"></a>
@@ -498,6 +499,8 @@ add_swagger_documentation \
498499
api_documentation: { desc: 'Reticulated splines API swagger-compatible documentation.' }
499500
```
500501

502+
`: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.
503+
501504
#### specific_api_documentation
502505

503506
Customize the Swagger API specific documentation route, typically contains a `desc` field. The default description is "Swagger compatible API description for specific API".

UPGRADING.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
## Upgrading Grape-swagger
22

3+
### Upgrading to >= 2.2.0
4+
5+
- **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.
6+
- **`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:`:
7+
8+
```ruby
9+
optional :foo, documentation: { type: 'Object' }
10+
```
11+
12+
grape-swagger picks the type up from the merged settings unchanged, so the swagger output is identical.
13+
- **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:
14+
15+
```ruby
16+
class MyType
17+
def self.parse(value) = new(value)
18+
# ...
19+
end
20+
```
21+
22+
- **Multi-type params (`type: [A, B]`) on Grape 3.2+**: swagger output now reflects the first declared type (e.g. `type: [Integer, Float]` produces `"integer"`). Previously, Grape 3.2+ 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.
23+
324
### Upgrading to >= x.y.z
425

526
- 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.

grape-swagger.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
1515
s.metadata['rubygems_mfa_required'] = 'true'
1616

1717
s.required_ruby_version = '>= 3.1'
18-
s.add_dependency 'grape', '>= 1.7', '< 4.0'
18+
s.add_dependency 'grape', '>= 2.1', '< 5.0'
1919

2020
s.files = Dir['lib/**/*', '*.md', 'LICENSE.txt', 'grape-swagger.gemspec']
2121
s.require_paths = ['lib']

lib/grape-swagger/doc_methods.rb

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,16 @@ def setup(options)
8686
# for available options see #defaults
8787
target_class = options[:target_class]
8888
guard = options[:swagger_endpoint_guard]
89-
api_doc = options[:api_documentation].dup
90-
specific_api_doc = options[:specific_api_documentation].dup
89+
# transform_keys normalizes string-keyed input, e.g. loaded from YAML/JSON.
90+
api_doc = (options[:api_documentation] || {}).transform_keys(&:to_sym)
91+
specific_api_doc = (options[:specific_api_documentation] || {}).transform_keys(&:to_sym)
9192

9293
class_variables_from(options)
9394

9495
setup_formatter(options[:format])
9596

96-
desc api_doc.delete(:desc), api_doc
97+
# Only the named-resource endpoint extracts :params for its required route param below.
98+
desc(pop_desc(api_doc), **api_doc)
9799

98100
instance_eval(guard) unless guard.nil?
99101

@@ -105,7 +107,9 @@ def setup(options)
105107
.output_path_definitions(target_class.combined_namespace_routes, self, target_class, options)
106108
end
107109

108-
desc specific_api_doc.delete(:desc), { params: specific_api_doc.delete(:params) || {}, **specific_api_doc }
110+
specific_desc = pop_desc(specific_api_doc)
111+
specific_params = specific_api_doc.delete(:params) || {}
112+
desc(specific_desc, params: specific_params, **specific_api_doc)
109113

110114
params do
111115
requires :name, type: String, desc: 'Resource name of mounted API'
@@ -136,5 +140,15 @@ def setup_formatter(formatter)
136140

137141
FORMATTER_METHOD.each { |method| send(method, formatter) }
138142
end
143+
144+
private
145+
146+
# explicit nil under :desc wins — don't fall through to :description
147+
def pop_desc(doc)
148+
result = doc.key?(:desc) ? doc.delete(:desc) : doc.delete(:description)
149+
# Also remove the alias so it does not leak into **doc kwargs.
150+
doc.delete(:description)
151+
result
152+
end
139153
end
140154
end

lib/grape-swagger/request_param_parsers/route.rb

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ def parse
1919
stackable_values = route.app&.inheritable_setting&.namespace_stackable
2020

2121
path_params = build_path_params(stackable_values)
22+
variant_types = collect_variant_types(stackable_values)
2223

23-
fulfill_params(path_params)
24+
fulfill_params(path_params, variant_types)
2425
end
2526

2627
private
@@ -47,7 +48,58 @@ def fetch_inherited_params(stackable_values)
4748
end
4849
end
4950

50-
def fulfill_params(path_params)
51+
# Grape 3.2+ serializes `type: [A, B]` via VariantCollectionCoercer#to_s, losing the type list.
52+
# Grape 3.2+ stores validator metadata as Hash entries; older supported versions use
53+
# CoerceValidator object instances and require private-ivar reads below.
54+
# If the internal structure changes in a future Grape version this silently returns {}.
55+
def collect_variant_types(stackable_values)
56+
variant_types = {}
57+
return variant_types unless defined?(Grape::Validations::Types::VariantCollectionCoercer) &&
58+
defined?(Grape::Validations::Validators::CoerceValidator) &&
59+
stackable_values.respond_to?(:[])
60+
61+
# StackableValues#[] concatenates this level and all inherited levels;
62+
# no explicit chain walk is needed here.
63+
(stackable_values[:validations] || []).each do |validator|
64+
attrs, scope, converter = extract_variant_validator_parts(validator)
65+
next unless attrs
66+
next unless converter.is_a?(Grape::Validations::Types::VariantCollectionCoercer)
67+
68+
# TODO: use a public API once Grape exposes VariantCollectionCoercer#types.
69+
types = converter.instance_variable_get(:@types).to_a
70+
next if types.empty?
71+
72+
next unless scope.respond_to?(:full_name)
73+
74+
attrs.each do |attr|
75+
# Key format must match param.to_s in restore_variant_type.
76+
variant_types[scope.full_name(attr)] = types
77+
end
78+
end
79+
80+
variant_types
81+
end
82+
83+
def extract_variant_validator_parts(validator)
84+
if validator.is_a?(Hash)
85+
return unless validator[:validator_class] == Grape::Validations::Validators::CoerceValidator
86+
87+
attrs = Array(validator[:attributes])
88+
scope = validator[:params_scope]
89+
converter = validator[:options].is_a?(Hash) ? validator[:options][:type] : nil
90+
return [attrs, scope, converter]
91+
end
92+
93+
return unless validator.is_a?(Grape::Validations::Validators::CoerceValidator)
94+
return unless validator.respond_to?(:attrs)
95+
96+
attrs = Array(validator.attrs)
97+
scope = validator.instance_variable_get(:@scope)
98+
converter = validator.instance_variable_get(:@converter)
99+
[attrs, scope, converter]
100+
end
101+
102+
def fulfill_params(path_params, variant_types)
51103
# Merge path params options into route params
52104
route.params.each_with_object({}) do |(param, definition), accum|
53105
# The route.params hash includes both parametrized params (with a string as a key)
@@ -57,10 +109,18 @@ def fulfill_params(path_params)
57109
next if param.is_a?(String) && accum.key?(key)
58110

59111
defined_options = definition.is_a?(Hash) ? definition : {}
112+
defined_options = restore_variant_type(defined_options, param, variant_types)
60113
value = (path_params[param] || {}).merge(defined_options)
61114
accum[key] = value.empty? ? DEFAULT_PARAM_TYPE : value
62115
end
63116
end
117+
118+
def restore_variant_type(defined_options, param, variant_types)
119+
types = variant_types[param.to_s]
120+
return defined_options unless types
121+
122+
defined_options.merge(type: types)
123+
end
64124
end
65125
end
66126
end

0 commit comments

Comments
 (0)