diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf46fdb91..d1027a3114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ * [#2436](https://github.com/ruby-grape/grape/pull/2436): Update coverallsapp github-action - [@ericproulx](https://github.com/ericproulx). * [#2434](https://github.com/ruby-grape/grape/pull/2434): Implement nested `with` support in parameter dsl - [@numbata](https://github.com/numbata). * [#2438](https://github.com/ruby-grape/grape/pull/2438): Fix some Rack::Lint - [@ericproulx](https://github.com/ericproulx). +* [#2437](https://github.com/ruby-grape/grape/pull/2437): Add length validator - [@dhruvCW](https://github.com/dhruvCW). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index e346aa53d3..46ab57f3a6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ - [values](#values) - [except_values](#except_values) - [same_as](#same_as) + - [length](#length) - [regexp](#regexp) - [mutually_exclusive](#mutually_exclusive) - [exactly_one_of](#exactly_one_of) @@ -69,6 +70,7 @@ - [Custom Validation messages](#custom-validation-messages) - [presence, allow_blank, values, regexp](#presence-allow_blank-values-regexp) - [same_as](#same_as-1) + - [length](#length-1) - [all_or_none_of](#all_or_none_of-1) - [mutually_exclusive](#mutually_exclusive-1) - [exactly_one_of](#exactly_one_of-1) @@ -1709,6 +1711,20 @@ params do end ``` +#### `length` + +Parameters with types that support `#length` method can be restricted to have a specific length with the `:length` option. + +The validator accepts `:min` or `:max` or both options to validate that the value of the parameter is within the given limits. + +```ruby +params do + requires :str, type: String, length: { min: 3 } + requires :list, type: [Integer], length: { min: 3, max: 5 } + requires :hash, type: Hash, length: { max: 5 } +end +``` + #### `regexp` Parameters can be restricted to match a specific regular expression with the `:regexp` option. If the value does not match the regular expression an error will be returned. Note that this is true for both `requires` and `optional` parameters. @@ -2026,6 +2042,15 @@ params do end ``` +#### `length` + +```ruby +params do + requires :str, type: String, length: { min: 5, message: 'str is expected to be atleast 5 characters long' } + requires :list, type: [Integer], length: { min: 2, max: 3, message: 'list is expected to have between 2 and 3 elements' } +end +``` + #### `all_or_none_of` ```ruby diff --git a/lib/grape/locale/en.yml b/lib/grape/locale/en.yml index 9377e4c4e0..3ed7bcc3ab 100644 --- a/lib/grape/locale/en.yml +++ b/lib/grape/locale/en.yml @@ -10,6 +10,9 @@ en: values: 'does not have a valid value' except_values: 'has a value not allowed' same_as: 'is not the same as %{parameter}' + length: 'is expected to have length within %{min} and %{max}' + length_min: 'is expected to have length greater than or equal to %{min}' + length_max: 'is expected to have length less than or equal to %{max}' missing_vendor_option: problem: 'missing :vendor option' summary: 'when version using header, you must specify :vendor option' diff --git a/lib/grape/validations/attributes_doc.rb b/lib/grape/validations/attributes_doc.rb index f9e15c148a..c0d5ed9549 100644 --- a/lib/grape/validations/attributes_doc.rb +++ b/lib/grape/validations/attributes_doc.rb @@ -28,6 +28,9 @@ def extract_details(validations) details[:documentation] = documentation if documentation details[:default] = validations[:default] if validations.key?(:default) + + details[:min_length] = validations[:length][:min] if validations.key?(:length) && validations[:length].key?(:min) + details[:max_length] = validations[:length][:max] if validations.key?(:length) && validations[:length].key?(:max) end def document(attrs) diff --git a/lib/grape/validations/validators/length_validator.rb b/lib/grape/validations/validators/length_validator.rb new file mode 100644 index 0000000000..bcd0c95592 --- /dev/null +++ b/lib/grape/validations/validators/length_validator.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class LengthValidator < Base + def initialize(attrs, options, required, scope, **opts) + @min = options[:min] + @max = options[:max] + + super + + raise ArgumentError, 'min must be an integer greater than or equal to zero' if !@min.nil? && (!@min.is_a?(Integer) || @min.negative?) + raise ArgumentError, 'max must be an integer greater than or equal to zero' if !@max.nil? && (!@max.is_a?(Integer) || @max.negative?) + raise ArgumentError, "min #{@min} cannot be greater than max #{@max}" if !@min.nil? && !@max.nil? && @min > @max + end + + def validate_param!(attr_name, params) + param = params[attr_name] + + raise ArgumentError, "parameter #{param} does not support #length" unless param.respond_to?(:length) + + return unless (!@min.nil? && param.length < @min) || (!@max.nil? && param.length > @max) + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: build_message) + end + + def build_message + if options_key?(:message) + @option[:message] + elsif @min && @max + format I18n.t(:length, scope: 'grape.errors.messages'), min: @min, max: @max + elsif @min + format I18n.t(:length_min, scope: 'grape.errors.messages'), min: @min + else + format I18n.t(:length_max, scope: 'grape.errors.messages'), max: @max + end + end + end + end + end +end diff --git a/spec/grape/validations/attributes_doc_spec.rb b/spec/grape/validations/attributes_doc_spec.rb index f1ae0c93e1..d5ae81d2ff 100644 --- a/spec/grape/validations/attributes_doc_spec.rb +++ b/spec/grape/validations/attributes_doc_spec.rb @@ -31,7 +31,8 @@ presence: true, desc: 'Age of...', documentation: 'Age is...', - default: 1 + default: 1, + length: { min: 1, max: 13 } } end @@ -77,7 +78,9 @@ documentation: validations[:documentation], default: validations[:default], type: 'Integer', - values: valid_values + values: valid_values, + min_length: validations[:length][:min], + max_length: validations[:length][:max] } end diff --git a/spec/grape/validations/validators/length_spec.rb b/spec/grape/validations/validators/length_spec.rb new file mode 100644 index 0000000000..8fa9f84876 --- /dev/null +++ b/spec/grape/validations/validators/length_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +describe Grape::Validations::Validators::LengthValidator do + let_it_be(:app) do + Class.new(Grape::API) do + params do + requires :list, length: { min: 2, max: 3 } + end + post 'with_min_max' do + end + + params do + requires :list, type: [Integer], length: { min: 2 } + end + post 'with_min_only' do + end + + params do + requires :list, type: [Integer], length: { max: 3 } + end + post 'with_max_only' do + end + + params do + requires :list, type: Integer, length: { max: 3 } + end + post 'type_is_not_array' do + end + + params do + requires :list, type: Hash, length: { max: 3 } + end + post 'type_supports_length' do + end + + params do + requires :list, type: [Integer], length: { min: -3 } + end + post 'negative_min' do + end + + params do + requires :list, type: [Integer], length: { max: -3 } + end + post 'negative_max' do + end + + params do + requires :list, type: [Integer], length: { min: 2.5 } + end + post 'float_min' do + end + + params do + requires :list, type: [Integer], length: { max: 2.5 } + end + post 'float_max' do + end + + params do + requires :list, type: [Integer], length: { min: 15, max: 3 } + end + post 'min_greater_than_max' do + end + + params do + requires :list, type: [Integer], length: { min: 3, max: 3 } + end + post 'min_equal_to_max' do + end + + params do + requires :list, type: [JSON], length: { min: 0 } + end + post 'zero_min' do + end + + params do + requires :list, type: [JSON], length: { max: 0 } + end + post 'zero_max' do + end + + params do + requires :list, type: [Integer], length: { min: 2, message: 'not match' } + end + post '/custom-message' do + end + end + end + + describe '/with_min_max' do + context 'when length is within limits' do + it do + post '/with_min_max', list: [1, 2] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is exceeded' do + it do + post '/with_min_max', list: [1, 2, 3, 4, 5] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 2 and 3') + end + end + + context 'when length is less than minimum' do + it do + post '/with_min_max', list: [1] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 2 and 3') + end + end + end + + describe '/with_max_only' do + context 'when length is less than limits' do + it do + post '/with_max_only', list: [1, 2] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is exceeded' do + it do + post '/with_max_only', list: [1, 2, 3, 4, 5] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length less than or equal to 3') + end + end + end + + describe '/with_min_only' do + context 'when length is greater than limit' do + it do + post '/with_min_only', list: [1, 2] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is less than limit' do + it do + post '/with_min_only', list: [1] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length greater than or equal to 2') + end + end + end + + describe '/zero_min' do + context 'when length is equal to the limit' do + it do + post '/zero_min', list: '[]' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is greater than limit' do + it do + post '/zero_min', list: [{ key: 'value' }] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + end + + describe '/zero_max' do + context 'when length is within the limit' do + it do + post '/zero_max', list: '[]' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is greater than limit' do + it do + post '/zero_max', list: [{ key: 'value' }] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length less than or equal to 0') + end + end + end + + describe '/type_is_not_array' do + context 'raises an error' do + it do + expect do + post 'type_is_not_array', list: 12 + end.to raise_error(ArgumentError, 'parameter 12 does not support #length') + end + end + end + + describe '/type_supports_length' do + context 'when length is within limits' do + it do + post 'type_supports_length', list: { key: 'value' } + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length exceeds the limit' do + it do + post 'type_supports_length', list: { key: 'value', key1: 'value', key3: 'value', key4: 'value' } + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length less than or equal to 3') + end + end + end + + describe '/negative_min' do + context 'when min is negative' do + it do + expect { post 'negative_min', list: [12] }.to raise_error(ArgumentError, 'min must be an integer greater than or equal to zero') + end + end + end + + describe '/negative_max' do + context 'it raises an error' do + it do + expect { post 'negative_max', list: [12] }.to raise_error(ArgumentError, 'max must be an integer greater than or equal to zero') + end + end + end + + describe '/float_min' do + context 'when min is not an integer' do + it do + expect { post 'float_min', list: [12] }.to raise_error(ArgumentError, 'min must be an integer greater than or equal to zero') + end + end + end + + describe '/float_max' do + context 'when max is not an integer' do + it do + expect { post 'float_max', list: [12] }.to raise_error(ArgumentError, 'max must be an integer greater than or equal to zero') + end + end + end + + describe '/min_greater_than_max' do + context 'raises an error' do + it do + expect { post 'min_greater_than_max', list: [1, 2] }.to raise_error(ArgumentError, 'min 15 cannot be greater than max 3') + end + end + end + + describe '/min_equal_to_max' do + context 'when array meets expectations' do + it do + post 'min_equal_to_max', list: [1, 2, 3] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when array is less than min' do + it do + post 'min_equal_to_max', list: [1, 2] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 3 and 3') + end + end + + context 'when array is greater than max' do + it do + post 'min_equal_to_max', list: [1, 2, 3, 4] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 3 and 3') + end + end + end + + describe '/custom-message' do + context 'is within limits' do + it do + post '/custom-message', list: [1, 2, 3] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'is outside limit' do + it do + post '/custom-message', list: [1] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list not match') + end + end + end +end