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