diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce50eda..0bab3faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ #### Fixes +* [#905](https://github.com/ruby-grape/grape-swagger/pull/905): Allow Float, BigDecimal and beginless ranges in `values` to set `minimum` and `maximum`, and skip infinite bounds that cannot be serialized to JSON - [@artyomb](https://github.com/artyomb). * [#1016](https://github.com/ruby-grape/grape-swagger/pull/1016): Hide the nested parameters of a Hash parameter marked `documentation: { hidden: true }`, and pass the resolved `token_owner` to parameter-level `hidden:` procs like route-level ones (raising `TokenOwnerNotFound` if the configured method is missing) - [@numbata](https://github.com/numbata). * [#1013](https://github.com/ruby-grape/grape-swagger/pull/1013): Fix array `default` values being placed inside `items` instead of on the array parameter or property, and emit an empty array `default` - [@numbata](https://github.com/numbata). * [#1012](https://github.com/ruby-grape/grape-swagger/pull/1012): Keep the Danger PR check running in CI by pinning `git` below 6 - [@numbata](https://github.com/numbata). diff --git a/lib/grape-swagger/doc_methods/move_params.rb b/lib/grape-swagger/doc_methods/move_params.rb index fd1823e1..e92d647e 100644 --- a/lib/grape-swagger/doc_methods/move_params.rb +++ b/lib/grape-swagger/doc_methods/move_params.rb @@ -178,8 +178,8 @@ def parse_model(ref) end def property_keys - %i[type format description minimum maximum items enum default additional_properties additionalProperties - example minLength maxLength minProperties maxProperties] + %i[type format description minimum maximum exclusiveMaximum items enum default additional_properties + additionalProperties example minLength maxLength minProperties maxProperties] end def array_schema_keys diff --git a/lib/grape-swagger/doc_methods/parse_params.rb b/lib/grape-swagger/doc_methods/parse_params.rb index 3e12889d..4350a482 100644 --- a/lib/grape-swagger/doc_methods/parse_params.rb +++ b/lib/grape-swagger/doc_methods/parse_params.rb @@ -193,7 +193,7 @@ def parse_enum_or_range_values(values) when Proc parse_enum_or_range_values(values.call) if values.parameters.empty? when Range - parse_range_values(values) if values.first.is_a?(Integer) + parse_range_values(values) if range_bounds_real?(values) when Array { enum: values } else @@ -202,7 +202,26 @@ def parse_enum_or_range_values(values) end def parse_range_values(values) - { minimum: values.begin, maximum: values.end }.compact + range = { minimum: range_bound(values.begin), maximum: range_bound(values.end) }.compact + range[:exclusiveMaximum] = true if values.exclude_end? && range.key?(:maximum) + range + end + + def range_bounds_real?(values) + bounds = [values.begin, values.end].compact + bounds.any? && bounds.all? { |bound| real_number?(bound) } + end + + def real_number?(bound) + bound.is_a?(Numeric) && bound.real? + end + + # Only finite numbers are valid JSON; BigDecimal and Rational are emitted as Integer when whole, else Float. + def range_bound(bound) + return unless real_number?(bound) && bound.finite? + return bound if bound.is_a?(Integer) || bound.is_a?(Float) + + bound.to_i == bound ? bound.to_i : bound.to_f end end end diff --git a/spec/lib/parse_params_spec.rb b/spec/lib/parse_params_spec.rb index 51e82f71..4a4ac205 100644 --- a/spec/lib/parse_params_spec.rb +++ b/spec/lib/parse_params_spec.rb @@ -37,6 +37,94 @@ end end + describe 'first Float' do + specify do + parsed_range = subject.send(:parse_enum_or_range_values, -5.0..5.0) + expect(parsed_range).to eql(minimum: -5.0, maximum: 5.0) + end + end + + describe 'with an infinite end' do + specify do + expect(subject.send(:parse_enum_or_range_values, 1.0..Float::INFINITY)).to eql(minimum: 1.0) + expect(subject.send(:parse_enum_or_range_values, 1..Float::INFINITY)).to eql(minimum: 1) + end + end + + describe 'with an infinite begin' do + specify do + parsed_range = subject.send(:parse_enum_or_range_values, -Float::INFINITY..5.0) + expect(parsed_range).to eql(maximum: 5.0) + end + end + + describe 'beginless' do + specify do + expect(subject.send(:parse_enum_or_range_values, ..5.0)).to eql(maximum: 5.0) + expect(subject.send(:parse_enum_or_range_values, ..5)).to eql(maximum: 5) + end + end + + describe 'endless Float' do + specify do + parsed_range = subject.send(:parse_enum_or_range_values, 1.5..) + expect(parsed_range).to eql(minimum: 1.5) + end + end + + describe 'BigDecimal' do + specify do + parsed_range = subject.send(:parse_enum_or_range_values, BigDecimal('1')..BigDecimal('2.5')) + expect(parsed_range).to eql(minimum: 1, maximum: 2.5) + end + + it 'keeps whole values beyond Float precision exact' do + parsed_range = subject.send(:parse_enum_or_range_values, BigDecimal('9007199254740993')..) + expect(parsed_range).to eql(minimum: 9_007_199_254_740_993) + end + + it 'skips NaN and infinite bounds' do + expect(subject.send(:parse_enum_or_range_values, ..BigDecimal('NaN'))).to eql({}) + expect(subject.send(:parse_enum_or_range_values, BigDecimal('-Infinity')..BigDecimal('3'))).to eql(maximum: 3) + end + end + + describe 'Rational' do + specify do + parsed_range = subject.send(:parse_enum_or_range_values, 1r..3/2r) + expect(parsed_range).to eql(minimum: 1, maximum: 1.5) + end + end + + describe 'Float NaN' do + specify do + parsed_range = subject.send(:parse_enum_or_range_values, Float::NAN..) + expect(parsed_range).to eql({}) + end + end + + describe 'with an exclusive end' do + specify do + expect(subject.send(:parse_enum_or_range_values, 1...5)) + .to eql(minimum: 1, maximum: 5, exclusiveMaximum: true) + expect(subject.send(:parse_enum_or_range_values, 0.0...1.0)) + .to eql(minimum: 0.0, maximum: 1.0, exclusiveMaximum: true) + end + + it 'does not flag an endless or infinite end' do + expect(subject.send(:parse_enum_or_range_values, 1...)).to eql(minimum: 1) + expect(subject.send(:parse_enum_or_range_values, 1.0...Float::INFINITY)).to eql(minimum: 1.0) + end + end + + describe 'with Complex bounds' do + specify do + expect(subject.send(:parse_enum_or_range_values, ..Complex(1, 2))).to be_nil + expect(subject.send(:parse_enum_or_range_values, Complex(1, 2)..)).to be_nil + expect(subject.send(:parse_enum_or_range_values, 1..Complex(2, 0))).to be_nil + end + end + describe 'first String' do specify do parsed_range = subject.send(:parse_enum_or_range_values, 'a'..'z') diff --git a/spec/swagger_v2/param_values_spec.rb b/spec/swagger_v2/param_values_spec.rb index 0220a9e7..b4a7b109 100644 --- a/spec/swagger_v2/param_values_spec.rb +++ b/spec/swagger_v2/param_values_spec.rb @@ -139,6 +139,47 @@ def app { message: 'hi' } end + desc 'range_infinite_float' do + consumes ['application/x-www-form-urlencoded'] + end + params do + requires :float, type: Float, values: 1.0..Float::INFINITY + end + post :range_infinite_float do + { message: 'hi' } + end + + desc 'range_exclusive_float' do + consumes ['application/x-www-form-urlencoded'] + end + params do + requires :float, type: Float, values: 0.0...1.0 + end + post :range_exclusive_float do + { message: 'hi' } + end + + desc 'range_exclusive_body' do + consumes ['application/json'] + end + params do + requires :float, type: Float, values: 0.0...1.0 + requires :list, type: Array[Integer], values: 1...5 + end + post :range_exclusive_body do + { message: 'hi' } + end + + desc 'range_exclusive_query_array' do + consumes ['application/json'] + end + params do + requires :list, type: Array[Integer], values: 1...5 + end + get :range_exclusive_query_array do + { message: 'hi' } + end + add_swagger_documentation end end @@ -167,13 +208,55 @@ def first_parameter_info(request) context 'Range values' do subject(:range_float) { first_parameter_info('range_float') } - it 'has float range values as string' do + it 'has float range values as minimum and maximum' do expect(range_float).to eq [{ 'in' => 'formData', 'name' => 'float', 'type' => 'number', 'required' => true, - 'format' => 'float' + 'format' => 'float', + 'minimum' => -5.0, + 'maximum' => 5.0 + }] + end + end + + context 'Range values with an exclusive end' do + subject(:range_exclusive_float) { first_parameter_info('range_exclusive_float') } + + it 'flags the maximum as exclusive' do + expect(range_exclusive_float.first).to include('minimum' => 0.0, 'maximum' => 1.0, 'exclusiveMaximum' => true) + end + end + + context 'Range values with an exclusive end in a query array' do + it 'keeps exclusiveMaximum on the array items' do + get '/swagger_doc/range_exclusive_query_array' + parameters = JSON.parse(last_response.body)['paths']['/range_exclusive_query_array']['get']['parameters'] + expect(parameters.first['items']).to include('minimum' => 1, 'maximum' => 5, 'exclusiveMaximum' => true) + end + end + + context 'Range values with an exclusive end in a body definition' do + it 'keeps exclusiveMaximum on properties and array items' do + get '/swagger_doc/range_exclusive_body' + props = JSON.parse(last_response.body)['definitions']['postRangeExclusiveBody']['properties'] + expect(props['float']).to include('maximum' => 1.0, 'exclusiveMaximum' => true) + expect(props['list']['items']).to include('minimum' => 1, 'maximum' => 5, 'exclusiveMaximum' => true) + end + end + + context 'Range values with an infinite end' do + subject(:range_infinite_float) { first_parameter_info('range_infinite_float') } + + it 'has only the finite bound as minimum' do + expect(range_infinite_float).to eq [{ + 'in' => 'formData', + 'name' => 'float', + 'type' => 'number', + 'required' => true, + 'format' => 'float', + 'minimum' => 1.0 }] end end