Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
```
Expand Down
14 changes: 14 additions & 0 deletions spec/grape/validations/validators/except_values_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
269 changes: 269 additions & 0 deletions spec/grape/validations/validators/values_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading