From b869c9aea6747e6e38c050c7659595ca4897adb3 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Sun, 26 Jul 2026 20:22:36 -0400 Subject: [PATCH] Document and test open ranges as an alternative to a numericality validator Instead of adding a new numericality validator (#2822), extend test coverage and documentation for the existing :values option, which already supports open (beginless/endless) Ranges via Range#include?. This covers the same common cases (positive numbers, percentages, exact values, and array element-wise checks) without a new validator. Ref: https://github.com/ruby-grape/grape/pull/2822#issuecomment-5084586238 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 24 +- .../except_values_validator_spec.rb | 14 + .../validators/values_validator_spec.rb | 269 ++++++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b14f0d7..d3c6173f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ * [#2811](https://github.com/ruby-grape/grape/pull/2811): Internalize the route scope behind `Grape::Util::InheritableSetting` accessors (`route_validations`, `route_declared_params`, `route_renamed_params` / `add_route_renamed_param`, `route_description`, `route_settings`, `route_setting`, `inherit_route_params`), so no external code touches the raw `route` store - [@ericproulx](https://github.com/ericproulx). * [#2823](https://github.com/ruby-grape/grape/pull/2823): Move stackable settings storage onto `Grape::Util::InheritableSetting`, resolving inheritance by walking `parent` instead of a second chain; `Grape::Util::StackableValues` becomes a read-only view kept for grape-swagger, and `Grape::Util::BaseInheritable` is removed - [@ericproulx](https://github.com/ericproulx). * [#2828](https://github.com/ruby-grape/grape/pull/2828): Compare `Grape::Util::InheritableSetting` by own state instead of serializing both chains through `to_hash`, and give `Grape::Util::InheritableValues` an `==`, so the duplicate-route check in `route` stops costing O(n²) `to_hash` calls on APIs whose namespaces share relative paths - [@ericproulx](https://github.com/ericproulx). +* [#2831](https://github.com/ruby-grape/grape/pull/2831): Document and test open (beginless/endless) `Range`s as a `values:`/`except_values:` alternative to a dedicated numericality validator - [@dblock](https://github.com/dblock). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index d14284e8b..4ee5beb20 100644 --- a/README.md +++ b/README.md @@ -1559,6 +1559,27 @@ params do end ``` +An exclusive upper bound is expressed with the standard Ruby `...` range (three dots). Ruby has no range syntax for an exclusive *lower* bound; an arity-one `:values` Proc predicate (see below) is the recommended way to express one, though excluding a specific endpoint (e.g. zero) via `:except_values` also works: + +```ruby +params do + requires :score, type: Float, values: 0.0...5.0 # 0.0 <= score < 5.0 + requires :positive, type: Float, values: ->(v) { v > 0.0 } # amount > 0.0 (recommended) + requires :other, type: Float, values: 0.0.., except_values: [0.0] # amount > 0.0 (alternative) +end +``` + +Combined with `:type`, ranges (open or closed) cover common numeric bound checks without a dedicated validator: + +```ruby +params do + requires :quantity, type: Integer, values: 1.. # must be a positive integer + requires :discount, type: Float, values: 0.0..100.0 # must be between 0 and 100 + requires :rating, type: Integer, values: 5..5 # must be exactly 5 + requires :numbers, type: [Integer], values: 1.. # every element must be positive +end +``` + The `:values` option can also be supplied with a `Proc`, evaluated lazily with each request. If the Proc has arity zero (i.e. it takes no arguments) it is expected to return either a list or a range which will then be used to validate the parameter. @@ -1594,12 +1615,13 @@ With `requires`, blank values are already rejected: `requires` enforces presence Parameters can be restricted from having a specific set of values with the `:except_values` option. -The `except_values` validator behaves similarly to the `values` validator in that it accepts either an Array, a Range, or a Proc. Unlike the `values` validator, however, `except_values` only accepts Procs with arity zero. +The `except_values` validator behaves similarly to the `values` validator in that it accepts either an Array, a Range (including open/beginless/endless ranges), or a Proc. Unlike the `values` validator, however, `except_values` only accepts Procs with arity zero. ```ruby params do requires :browser, except_values: [ 'ie6', 'ie7', 'ie8' ] requires :port, except_values: { value: 0..1024, message: 'is not allowed' } + requires :negative, type: Integer, except_values: ..-1 requires :hashtag, except_values: -> { Hashtag.FORBIDDEN_LIST } end ``` diff --git a/spec/grape/validations/validators/except_values_validator_spec.rb b/spec/grape/validations/validators/except_values_validator_spec.rb index 9b7442e4d..1ac32d612 100644 --- a/spec/grape/validations/validators/except_values_validator_spec.rb +++ b/spec/grape/validations/validators/except_values_validator_spec.rb @@ -157,6 +157,20 @@ { value: 11, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, { value: 13, rc: 200, body: { type: 13 }.to_json } ] + }, + req_except_endless_range: { + optional: { type: Integer, except_values: 100.. }, + tests: [ + { value: 150, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 50, rc: 200, body: { type: 50 }.to_json } + ] + }, + req_except_beginless_range: { + optional: { type: Integer, except_values: ..0 }, + tests: [ + { value: -5, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 5, rc: 200, body: { type: 5 }.to_json } + ] } }.each do |path, param_def| param_def[:tests].each do |t| diff --git a/spec/grape/validations/validators/values_validator_spec.rb b/spec/grape/validations/validators/values_validator_spec.rb index 5ee07b46c..e40a2e138 100644 --- a/spec/grape/validations/validators/values_validator_spec.rb +++ b/spec/grape/validations/validators/values_validator_spec.rb @@ -330,6 +330,275 @@ def default_excepts end end + describe '/beginless' do + let(:app) do + Class.new(Grape::API) do + default_format :json + + params do + optional :type, type: Integer, values: ..10 + end + get '/beginless' do + { type: params[:type] } + end + end + end + + it 'validates against values in a beginless range' do + get('/beginless', type: 5) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ type: 5 }.to_json) + end + + it 'does not allow an invalid value for a parameter using a beginless range' do + get('/beginless', type: 11) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'type does not have a valid value' }.to_json) + end + end + + # Ruby's `...` range excludes its upper bound; there is no equivalent for an exclusive + # lower bound, so that is achieved by combining `values:` with `except_values:`. + describe '/exclusive_bounds' do + let(:app) do + Class.new(Grape::API) do + default_format :json + + resources :exclusive_bounds do + params do + requires :score, type: Float, values: 0.0...5.0 + end + get '/exclusive_end' do + { score: params[:score] } + end + + params do + requires :amount, type: Float, values: 0.0.., except_values: [0.0] + end + get '/exclusive_start' do + { amount: params[:amount] } + end + + params do + requires :amount, type: Float, values: ->(v) { v > 0.0 } + end + get '/exclusive_start_predicate' do + { amount: params[:amount] } + end + end + end + end + + context 'with an exclusive upper bound (0.0...5.0)' do + it 'allows a value just below the upper bound' do + get('/exclusive_bounds/exclusive_end', score: 4.9999) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ score: 4.9999 }.to_json) + end + + it 'rejects the upper bound itself' do + get('/exclusive_bounds/exclusive_end', score: 5.0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'score does not have a valid value' }.to_json) + end + + it 'allows the lower bound (inclusive)' do + get('/exclusive_bounds/exclusive_end', score: 0.0) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ score: 0.0 }.to_json) + end + end + + context 'with an exclusive lower bound (via values: 0.0.. + except_values: [0.0])' do + it 'allows a value just above the lower bound' do + get('/exclusive_bounds/exclusive_start', amount: 0.0001) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ amount: 0.0001 }.to_json) + end + + it 'rejects exactly the excluded lower bound' do + get('/exclusive_bounds/exclusive_start', amount: 0.0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'amount has a value not allowed' }.to_json) + end + + it 'rejects a value below the lower bound' do + get('/exclusive_bounds/exclusive_start', amount: -1.0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'amount does not have a valid value' }.to_json) + end + end + + context 'with an exclusive lower bound (via values: ->(v) { v > 0.0 }, the cleaner alternative)' do + it 'allows a value just above the lower bound' do + get('/exclusive_bounds/exclusive_start_predicate', amount: 0.0001) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ amount: 0.0001 }.to_json) + end + + it 'rejects exactly the lower bound' do + get('/exclusive_bounds/exclusive_start_predicate', amount: 0.0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'amount does not have a valid value' }.to_json) + end + + it 'rejects a value below the lower bound' do + get('/exclusive_bounds/exclusive_start_predicate', amount: -1.0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'amount does not have a valid value' }.to_json) + end + end + end + + # Open (beginless/endless) ranges cover the common numeric-bounding needs (positive + # numbers, percentages, exact values) without a dedicated `numericality` validator, since + # `values:` already accepts a `Range` and checks it with `Range#include?`. + # See https://github.com/ruby-grape/grape/pull/2822#issuecomment-5084586238 + describe '/open_ranges' do + let(:app) do + Class.new(Grape::API) do + default_format :json + + resources :open_ranges do + params do + requires :quantity, type: Integer, values: 1.. + end + get '/quantity' do + { quantity: params[:quantity] } + end + + params do + requires :discount, type: Float, values: 0.0..100.0 + end + get '/discount' do + { discount: params[:discount] } + end + + params do + requires :rating, type: Integer, values: 5..5 + end + get '/rating' do + { rating: params[:rating] } + end + + params do + requires :page, type: Integer, values: 1.. + end + get '/page' do + { page: params[:page] } + end + + params do + requires :numbers, type: [Integer], values: 1.. + end + get '/numbers' do + { numbers: params[:numbers] } + end + end + end + end + + context 'when a positive quantity is required (greater_than: 0)' do + it 'allows a positive value' do + get('/open_ranges/quantity', quantity: 1) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ quantity: 1 }.to_json) + end + + it 'rejects zero' do + get('/open_ranges/quantity', quantity: 0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'quantity does not have a valid value' }.to_json) + end + + it 'rejects a negative value' do + get('/open_ranges/quantity', quantity: -1) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'quantity does not have a valid value' }.to_json) + end + end + + context 'when a percentage is required (greater_than_or_equal_to: 0, less_than_or_equal_to: 100)' do + it 'allows a value within range' do + get('/open_ranges/discount', discount: 50) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ discount: 50.0 }.to_json) + end + + it 'allows the lower bound' do + get('/open_ranges/discount', discount: 0) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ discount: 0.0 }.to_json) + end + + it 'allows the upper bound' do + get('/open_ranges/discount', discount: 100) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ discount: 100.0 }.to_json) + end + + it 'rejects a value below the range' do + get('/open_ranges/discount', discount: -1) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'discount does not have a valid value' }.to_json) + end + + it 'rejects a value above the range' do + get('/open_ranges/discount', discount: 101) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'discount does not have a valid value' }.to_json) + end + end + + context 'when an exact value is required (equal_to: 5)' do + it 'allows the exact value' do + get('/open_ranges/rating', rating: 5) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ rating: 5 }.to_json) + end + + it 'rejects any other value' do + get('/open_ranges/rating', rating: 4) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'rating does not have a valid value' }.to_json) + end + end + + context 'when a positive integer is required (greater_than: 0, only_integer: true)' do + it 'allows a positive integer' do + get('/open_ranges/page', page: 1) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ page: 1 }.to_json) + end + + it 'rejects a non-integer value' do + get('/open_ranges/page', page: 1.5) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'page is invalid, page does not have a valid value' }.to_json) + end + + it 'rejects zero' do + get('/open_ranges/page', page: 0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'page does not have a valid value' }.to_json) + end + end + + context 'when every array element must satisfy the constraint (greater_than: 0)' do + it 'allows an array whose elements all satisfy the constraint' do + get('/open_ranges/numbers', numbers: [1, 2, 3]) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ numbers: [1, 2, 3] }.to_json) + end + + it 'rejects an array with an element violating the constraint' do + get('/open_ranges/numbers', numbers: [1, 0, 3]) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'numbers does not have a valid value' }.to_json) + end + end + end + describe '/lambda_val' do let(:app) do Class.new(Grape::API) do