From 969e0f28965d2f98e8d156c89864dd2456f8b6ce Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sun, 26 Jul 2026 16:51:43 +0200 Subject: [PATCH] Add a numericality validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grape has no built-in way to bound or shape-check a numeric parameter beyond `values:` (a closed inclusive range or list). Users end up reaching for a `values:` lambda or a full `contract_scope` schema for something as simple as "must be a positive integer" or "0..100 inclusive with an exclusive upper bound", both of which are more ceremony than the check deserves and produce weak error messages. Add `NumericalityValidator`, registered as `numericality`, following ActiveModel::Validations::NumericalityValidator's option naming for familiarity: `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `equal_to`, `other_than`, `only_integer`, `odd`, `even`. Any combination can be supplied and each has its own i18n message key (mirroring how `length` has separate `length_min`/ `length_max`/`length_is` keys). Design notes: - Non-Numeric values are skipped rather than erroring — numeric-ness is `type:`/coercion's job; this validator only owns bounds/parity, so it composes with `coerce` instead of duplicating it. - Applied to a typed array (`type: [Integer]`), every element is checked individually (`Array.wrap`), matching how `values` already treats arrays — `length` is the one that owns the array's own size. - `only_integer` accepts whole-numbered `Float`/`BigDecimal` too (`val == val.to_i`), not just literal `Integer`. - Obviously contradictory option combinations (`greater_than` + `greater_than_or_equal_to`, `less_than` + `less_than_or_equal_to`, `odd` + `even`, `equal_to` + any other comparison, or a reversed lower/upper bound) raise `ArgumentError` at definition time, same as `length_validator`'s existing `min > max` guard. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + README.md | 39 ++ lib/grape/locale/en.yml | 9 + .../validators/numericality_validator.rb | 89 +++ .../validators/numericality_validator_spec.rb | 506 ++++++++++++++++++ 5 files changed, 644 insertions(+) create mode 100644 lib/grape/validations/validators/numericality_validator.rb create mode 100644 spec/grape/validations/validators/numericality_validator_spec.rb 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