diff --git a/CHANGELOG.md b/CHANGELOG.md index 22eaf4dfc..30f73cd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ * [#2808](https://github.com/ruby-grape/grape/pull/2808): Internalize `build_params_with` and `auth` behind `Grape::Util::InheritableSetting` accessors - [@ericproulx](https://github.com/ericproulx). * [#2807](https://github.com/ruby-grape/grape/pull/2807): Internalize routing scope flags behind `Grape::Util::InheritableSetting` accessors (`do_not_route_head`, `do_not_route_options`, `do_not_document`, `lint` — `!` writers / `?` readers), and drop the vestigial `namespace_inheritable` hash copy in `Grape::API::Instance`'s route-config collection - [@ericproulx](https://github.com/ericproulx). * [#2810](https://github.com/ruby-grape/grape/pull/2810): Make `Grape::Util::InheritableSetting`'s raw stores internal where the ecosystem allows: `namespace_inheritable` is now protected and `namespace_stackable_with_hash` private, while `namespace_stackable` stays public for grape-swagger; `Router::Pattern::Path` reads a dedicated `path_settings` snapshot, and `mount_paths` exposes the full mount-path stack - [@ericproulx](https://github.com/ericproulx). +* [#2822](https://github.com/ruby-grape/grape/pull/2822): Add a `numericality` validator (`greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `equal_to`, `other_than`, `only_integer`, `odd`, `even`) - [@ericproulx](https://github.com/ericproulx). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index d14284e8b..3b1657808 100644 --- a/README.md +++ b/README.md @@ -1630,6 +1630,37 @@ params do end ``` +#### `numericality` + +Numeric parameters can be restricted to a range or a shape with the `:numericality` option. It accepts any combination of `:greater_than`, `:greater_than_or_equal_to`, `:less_than`, `:less_than_or_equal_to`, `:equal_to`, `:other_than`, `:only_integer`, `:odd` and `:even`. + +```ruby +params do + requires :quantity, type: Integer, numericality: { greater_than: 0 } + requires :discount, type: Float, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + requires :rating, type: Integer, numericality: { equal_to: 5 } + requires :page, type: Integer, numericality: { greater_than: 0, only_integer: true } +end +``` + +`:only_integer` accepts whole-numbered `Float`/`BigDecimal` values as well as `Integer`, so it also works on non-integer types: + +```ruby +params do + requires :amount, type: Float, numericality: { only_integer: true } # 5.0 is valid, 5.5 is not +end +``` + +When applied to a typed array, every element is checked individually: + +```ruby +params do + requires :numbers, type: [Integer], numericality: { greater_than: 0 } +end +``` + +`:equal_to` cannot be combined with any other comparison option, `:greater_than`/`:greater_than_or_equal_to` cannot be combined with each other (same for the `:less_than` pair), and `:odd`/`:even` are mutually exclusive; combining them raises an `ArgumentError` when the params are defined. Values that aren't `Numeric` (e.g. because no `:type`/coercion was declared) are left untouched by this validator — pair it with `:type` to enforce numeric-ness. + #### `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. @@ -2022,6 +2053,14 @@ params do end ``` +#### `numericality` + +```ruby +params do + requires :rating, type: Integer, numericality: { equal_to: 5, message: 'must be a perfect score' } +end +``` + #### `all_or_none_of` ```ruby diff --git a/lib/grape/locale/en.yml b/lib/grape/locale/en.yml index a084275a0..995846f1b 100644 --- a/lib/grape/locale/en.yml +++ b/lib/grape/locale/en.yml @@ -43,6 +43,15 @@ en: resolution: 'eg: version ''v1'', using: :header, vendor: ''twitter''' summary: 'when version using header, you must specify :vendor option' mutual_exclusion: 'are mutually exclusive' + numericality_equal_to: 'must be equal to %{count}' + numericality_even: 'must be even' + numericality_greater_than: 'must be greater than %{count}' + numericality_greater_than_or_equal_to: 'must be greater than or equal to %{count}' + numericality_less_than: 'must be less than %{count}' + numericality_less_than_or_equal_to: 'must be less than or equal to %{count}' + numericality_odd: 'must be odd' + numericality_only_integer: 'must be an integer' + numericality_other_than: 'must be other than %{count}' oneof: 'does not match any of the allowed schemas' presence: 'is missing' regexp: 'is invalid' diff --git a/lib/grape/validations/validators/numericality_validator.rb b/lib/grape/validations/validators/numericality_validator.rb new file mode 100644 index 000000000..cac6e076c --- /dev/null +++ b/lib/grape/validations/validators/numericality_validator.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class NumericalityValidator < Base + COMPARISON_OPTIONS = %i[greater_than greater_than_or_equal_to less_than less_than_or_equal_to equal_to other_than].freeze + + def initialize(attrs, options, required, scope, opts) + super + + @greater_than, @greater_than_or_equal_to, @less_than, @less_than_or_equal_to, @equal_to, @other_than = + options.values_at(*COMPARISON_OPTIONS) + @only_integer, @odd, @even = options.values_at(:only_integer, :odd, :even) + + validate_options! + end + + def validate_param!(attr_name, params) + return unless hash_like?(params) + + Array.wrap(params[attr_name]).each { |val| check_value!(attr_name, val) } + end + + private + + def check_value!(attr_name, val) + return unless val.is_a?(Numeric) + + check_shape!(attr_name, val) + check_comparisons!(attr_name, val) + end + + def check_shape!(attr_name, val) + fail!(attr_name, :only_integer) if @only_integer && !whole?(val) + fail!(attr_name, :odd) if @odd && val.respond_to?(:odd?) && val.even? + fail!(attr_name, :even) if @even && val.respond_to?(:even?) && val.odd? + end + + def check_comparisons!(attr_name, val) + fail!(attr_name, :equal_to, @equal_to) if @equal_to && val != @equal_to + fail!(attr_name, :other_than, @other_than) if @other_than && val == @other_than + fail!(attr_name, :greater_than, @greater_than) if @greater_than && val <= @greater_than + fail!(attr_name, :greater_than_or_equal_to, @greater_than_or_equal_to) if @greater_than_or_equal_to && val < @greater_than_or_equal_to + fail!(attr_name, :less_than, @less_than) if @less_than && val >= @less_than + fail!(attr_name, :less_than_or_equal_to, @less_than_or_equal_to) if @less_than_or_equal_to && val > @less_than_or_equal_to + end + + def whole?(val) + val.is_a?(Integer) || (val.respond_to?(:to_i) && val == val.to_i) + end + + def fail!(attr_name, key, count = nil) + validation_error!(attr_name, message { translate(:"numericality_#{key}", count:) }) + end + + def validate_options! + validate_numeric_option!(:greater_than, @greater_than) + validate_numeric_option!(:greater_than_or_equal_to, @greater_than_or_equal_to) + validate_numeric_option!(:less_than, @less_than) + validate_numeric_option!(:less_than_or_equal_to, @less_than_or_equal_to) + validate_numeric_option!(:equal_to, @equal_to) + validate_numeric_option!(:other_than, @other_than) + + raise ArgumentError, 'greater_than cannot be combined with greater_than_or_equal_to' if @greater_than && @greater_than_or_equal_to + raise ArgumentError, 'less_than cannot be combined with less_than_or_equal_to' if @less_than && @less_than_or_equal_to + raise ArgumentError, 'odd cannot be combined with even' if @odd && @even + + other_comparisons_given = @greater_than || @greater_than_or_equal_to || @less_than || @less_than_or_equal_to || @other_than + raise ArgumentError, 'equal_to cannot be combined with other comparison options' if @equal_to && other_comparisons_given + + validate_range! + end + + def validate_numeric_option!(name, val) + raise ArgumentError, "#{name} must be a Numeric value" if !val.nil? && !val.is_a?(Numeric) + end + + def validate_range! + lower = @greater_than || @greater_than_or_equal_to + upper = @less_than || @less_than_or_equal_to + return unless lower && upper && lower > upper + + raise ArgumentError, "#{lower} cannot be greater than #{upper}" + end + end + end + end +end diff --git a/spec/grape/validations/validators/numericality_validator_spec.rb b/spec/grape/validations/validators/numericality_validator_spec.rb new file mode 100644 index 000000000..d0b636e2d --- /dev/null +++ b/spec/grape/validations/validators/numericality_validator_spec.rb @@ -0,0 +1,506 @@ +# frozen_string_literal: true + +describe Grape::Validations::Validators::NumericalityValidator do + describe '/greater_than' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { greater_than: 0 } + end + post 'greater_than' do + end + end + end + + context 'when value is greater than the limit' do + it do + post '/greater_than', quantity: 1 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is equal to the limit' do + it do + post '/greater_than', quantity: 0 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('quantity must be greater than 0') + end + end + + context 'when value is less than the limit' do + it do + post '/greater_than', quantity: -1 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('quantity must be greater than 0') + end + end + end + + describe '/greater_than_or_equal_to' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { greater_than_or_equal_to: 0 } + end + post 'greater_than_or_equal_to' do + end + end + end + + context 'when value is equal to the limit' do + it do + post '/greater_than_or_equal_to', quantity: 0 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is less than the limit' do + it do + post '/greater_than_or_equal_to', quantity: -1 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('quantity must be greater than or equal to 0') + end + end + end + + describe '/less_than' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { less_than: 10 } + end + post 'less_than' do + end + end + end + + context 'when value is less than the limit' do + it do + post '/less_than', quantity: 9 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is equal to the limit' do + it do + post '/less_than', quantity: 10 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('quantity must be less than 10') + end + end + end + + describe '/less_than_or_equal_to' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { less_than_or_equal_to: 10 } + end + post 'less_than_or_equal_to' do + end + end + end + + context 'when value is equal to the limit' do + it do + post '/less_than_or_equal_to', quantity: 10 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is greater than the limit' do + it do + post '/less_than_or_equal_to', quantity: 11 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('quantity must be less than or equal to 10') + end + end + end + + describe '/equal_to' do + let(:app) do + Class.new(Grape::API) do + params do + requires :rating, type: Integer, numericality: { equal_to: 5 } + end + post 'equal_to' do + end + end + end + + context 'when value matches' do + it do + post '/equal_to', rating: 5 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value does not match' do + it do + post '/equal_to', rating: 4 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('rating must be equal to 5') + end + end + end + + describe '/other_than' do + let(:app) do + Class.new(Grape::API) do + params do + requires :code, type: Integer, numericality: { other_than: 0 } + end + post 'other_than' do + end + end + end + + context 'when value differs' do + it do + post '/other_than', code: 1 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value matches the excluded value' do + it do + post '/other_than', code: 0 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('code must be other than 0') + end + end + end + + describe '/range' do + let(:app) do + Class.new(Grape::API) do + params do + requires :discount, type: Float, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + end + post 'range' do + end + end + end + + context 'when value is within range' do + it do + post '/range', discount: 50.0 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is below range' do + it do + post '/range', discount: -1.0 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('discount must be greater than or equal to 0') + end + end + + context 'when value is above range' do + it do + post '/range', discount: 101.0 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('discount must be less than or equal to 100') + end + end + end + + describe '/only_integer' do + let(:app) do + Class.new(Grape::API) do + params do + requires :amount, type: Float, numericality: { only_integer: true } + end + post 'only_integer' do + end + end + end + + context 'when value is a whole number' do + it do + post '/only_integer', amount: 5.0 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value has a fractional part' do + it do + post '/only_integer', amount: 5.5 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('amount must be an integer') + end + end + end + + describe '/odd' do + let(:app) do + Class.new(Grape::API) do + params do + requires :number, type: Integer, numericality: { odd: true } + end + post 'odd' do + end + end + end + + context 'when value is odd' do + it do + post '/odd', number: 3 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is even' do + it do + post '/odd', number: 4 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('number must be odd') + end + end + end + + describe '/even' do + let(:app) do + Class.new(Grape::API) do + params do + requires :number, type: Integer, numericality: { even: true } + end + post 'even' do + end + end + end + + context 'when value is even' do + it do + post '/even', number: 4 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when value is odd' do + it do + post '/even', number: 3 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('number must be even') + end + end + end + + describe '/array_elements' do + let(:app) do + Class.new(Grape::API) do + params do + requires :numbers, type: [Integer], numericality: { greater_than: 0 } + end + post 'array_elements' do + end + end + end + + context 'when every element satisfies the constraint' do + it do + post '/array_elements', numbers: [1, 2, 3] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when one element violates the constraint' do + it do + post '/array_elements', numbers: [1, 0, 3] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('numbers must be greater than 0') + end + end + end + + describe '/non_numeric_value' do + let(:app) do + Class.new(Grape::API) do + params do + requires :code, numericality: { greater_than: 0 } + end + post 'non_numeric_value' do + end + end + end + + context 'does not raise an error' do + it do + post '/non_numeric_value', code: 'abc' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + end + + describe '/custom-message' do + let(:app) do + Class.new(Grape::API) do + params do + requires :rating, type: Integer, numericality: { equal_to: 5, message: 'must be a perfect score' } + end + post '/custom-message' do + end + end + end + + context 'is valid' do + it do + post '/custom-message', rating: 5 + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'is invalid' do + it do + post '/custom-message', rating: 4 + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('rating must be a perfect score') + end + end + end + + describe '/allow_blank' do + let(:app) do + Class.new(Grape::API) do + params do + optional :quantity, type: Integer, numericality: { greater_than: 0 }, allow_blank: false + end + post 'allow_blank' do + end + end + end + + context 'when not provided' do + it do + post '/allow_blank' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + end + + describe '/non_numeric_bound' do + context 'when a comparison option is not Numeric' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { greater_than: '0' } + end + post 'non_numeric_bound' do + end + end + end + + it do + expect { post 'non_numeric_bound', quantity: 1 }.to raise_error(ArgumentError, 'greater_than must be a Numeric value') + end + end + end + + describe '/greater_than_with_greater_than_or_equal_to' do + context 'when both options are combined' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { greater_than: 0, greater_than_or_equal_to: 1 } + end + post 'greater_than_with_greater_than_or_equal_to' do + end + end + end + + it do + expect { post 'greater_than_with_greater_than_or_equal_to', quantity: 1 } + .to raise_error(ArgumentError, 'greater_than cannot be combined with greater_than_or_equal_to') + end + end + end + + describe '/less_than_with_less_than_or_equal_to' do + context 'when both options are combined' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { less_than: 10, less_than_or_equal_to: 9 } + end + post 'less_than_with_less_than_or_equal_to' do + end + end + end + + it do + expect { post 'less_than_with_less_than_or_equal_to', quantity: 1 } + .to raise_error(ArgumentError, 'less_than cannot be combined with less_than_or_equal_to') + end + end + end + + describe '/odd_with_even' do + context 'when both options are combined' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { odd: true, even: true } + end + post 'odd_with_even' do + end + end + end + + it do + expect { post 'odd_with_even', quantity: 1 }.to raise_error(ArgumentError, 'odd cannot be combined with even') + end + end + end + + describe '/equal_to_with_greater_than' do + context 'when combined with another comparison option' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { equal_to: 5, greater_than: 0 } + end + post 'equal_to_with_greater_than' do + end + end + end + + it do + expect { post 'equal_to_with_greater_than', quantity: 5 } + .to raise_error(ArgumentError, 'equal_to cannot be combined with other comparison options') + end + end + end + + describe '/reversed_range' do + context 'when the lower bound is greater than the upper bound' do + let(:app) do + Class.new(Grape::API) do + params do + requires :quantity, type: Integer, numericality: { greater_than: 10, less_than: 5 } + end + post 'reversed_range' do + end + end + end + + it do + expect { post 'reversed_range', quantity: 1 }.to raise_error(ArgumentError, '10 cannot be greater than 5') + end + end + end +end