Skip to content

Bugfix for issue 699 - scoped mutual methods #774

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
@@ -1,6 +1,7 @@
0.9.1 (Next)
============

* [#774](https://github.com/intridea/grape/pull/774): Extended `mutually_exclusive`, `exactly_one_of`, `at_least_one_of` to work inside any kind of group: `requires` or `optional`, `Hash` or `Array` - [@ShPakvel](https://github.com/ShPakvel).
* [#743](https://github.com/intridea/grape/pull/743): Added `allow_blank` parameter validator to validate non-empty strings - [@elado](https://github.com/elado).
* Your contribution here.
* [#745](https://github.com/intridea/grape/pull/745): Removed `atom+xml`, `rss+xml`, and `jsonapi` content-types - [@akabraham](https://github.com/akabraham).
Expand Down
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ module Twitter
class API < Grape::API
version 'v1', using: :header, vendor: 'twitter'
format :json
prefix :api

helpers do
def current_user
Expand Down Expand Up @@ -620,6 +621,32 @@ params do
end
```

#### Nested `mutually_exclusive`, `exactly_one_of`, `at_least_one_of`

All of these methods can be used at any nested level.

```ruby
params do
requires :food do
optional :meat
optional :fish
optional :rice
at_least_one_of :meat, :fish, :rice
end
group :drink do
optional :beer
optional :wine
optional :juice
exactly_one_of :beer, :wine, :juice
end
optional :dessert do
optional :cake
optional :icecream
mutually_exclusive :cake, :icecream
end
end
```

### Namespace Validation and Coercion

Namespaces allow parameter definitions and apply to every method within the namespace.
Expand Down Expand Up @@ -1812,17 +1839,17 @@ describe Twitter::API do
end

describe Twitter::API do
describe "GET /api/v1/statuses" do
describe "GET /api/statuses/public_timeline" do
it "returns an empty array of statuses" do
get "/api/v1/statuses"
get "/api/statuses/public_timeline"
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body)).to eq []
end
end
describe "GET /api/v1/statuses/:id" do
describe "GET /api/statuses/:id" do
it "returns a status by id" do
status = Status.create!
get "/api/v1/statuses/#{status.id}"
get "/api/statuses/#{status.id}"
expect(last_response.body).to eq status.to_json
end
end
Expand All @@ -1834,17 +1861,17 @@ end

```ruby
describe Twitter::API do
describe "GET /api/v1/statuses" do
describe "GET /api/statuses/public_timeline" do
it "returns an empty array of statuses" do
get "/api/v1/statuses"
get "/api/statuses/public_timeline"
expect(response.status).to eq(200)
expect(JSON.parse(response.body)).to eq []
end
end
describe "GET /api/v1/statuses/:id" do
describe "GET /api/statuses/:id" do
it "returns a status by id" do
status = Status.create!
get "/api/v1/statuses/#{status.id}"
get "/api/statuses/#{status.id}"
expect(response.body).to eq status.to_json
end
end
Expand Down
4 changes: 4 additions & 0 deletions lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def root?
!@parent
end

def required?
!@optional
end

protected

def push_declared_params(attrs)
Expand Down
17 changes: 6 additions & 11 deletions lib/grape/validations/validators/at_least_one_of.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
module Grape
module Validations
class AtLeastOneOfValidator < Base
attr_reader :params

require 'grape/validations/validators/multiple_params_base'
class AtLeastOneOfValidator < MultipleParamsBase
def validate!(params)
@params = params
if no_exclusive_params_are_present
raise Grape::Exceptions::Validation, params: attrs.map(&:to_s), message_key: :at_least_one
super
if scope_requires_params && no_exclusive_params_are_present
raise Grape::Exceptions::Validation, params: all_keys, message_key: :at_least_one
end
params
end

private

def no_exclusive_params_are_present
keys_in_common.length == 0
end

def keys_in_common
(attrs.map(&:to_s) & params.stringify_keys.keys).map(&:to_s)
scoped_params.any? { |resource_params| keys_in_common(resource_params).empty? }
end
end
end
Expand Down
10 changes: 2 additions & 8 deletions lib/grape/validations/validators/exactly_one_of.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ module Grape
module Validations
require 'grape/validations/validators/mutual_exclusion'
class ExactlyOneOfValidator < MutualExclusionValidator
attr_reader :params

def validate!(params)
super
if none_of_restricted_params_is_present
if scope_requires_params && none_of_restricted_params_is_present
raise Grape::Exceptions::Validation, params: all_keys, message_key: :exactly_one
end
params
Expand All @@ -15,11 +13,7 @@ def validate!(params)
private

def none_of_restricted_params_is_present
keys_in_common.length < 1
end

def all_keys
attrs.map(&:to_s)
scoped_params.any? { |resource_params| keys_in_common(resource_params).empty? }
end
end
end
Expand Down
26 changes: 26 additions & 0 deletions lib/grape/validations/validators/multiple_params_base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module Grape
module Validations
class MultipleParamsBase < Base
attr_reader :scoped_params

def validate!(params)
@scoped_params = [@scope.params(params)].flatten
params
end

private

def scope_requires_params
@scope.required? || scoped_params.any? { |resource_params| resource_params.any? }
end

def keys_in_common(resource_params)
(all_keys & resource_params.stringify_keys.keys).map(&:to_s)
end

def all_keys
attrs.map(&:to_s)
end
end
end
end
18 changes: 9 additions & 9 deletions lib/grape/validations/validators/mutual_exclusion.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
module Grape
module Validations
class MutualExclusionValidator < Base
attr_reader :params
require 'grape/validations/validators/multiple_params_base'
class MutualExclusionValidator < MultipleParamsBase
attr_reader :processing_keys_in_common

def validate!(params)
@params = params
super
if two_or_more_exclusive_params_are_present
raise Grape::Exceptions::Validation, params: keys_in_common, message_key: :mutual_exclusion
raise Grape::Exceptions::Validation, params: processing_keys_in_common, message_key: :mutual_exclusion
end
params
end

private

def two_or_more_exclusive_params_are_present
keys_in_common.length > 1
end

def keys_in_common
(attrs.map(&:to_s) & params.stringify_keys.keys).map(&:to_s)
scoped_params.any? do |resource_params|
@processing_keys_in_common = keys_in_common(resource_params)
@processing_keys_in_common.length > 1
end
end
end
end
Expand Down
6 changes: 5 additions & 1 deletion spec/grape/validations/validators/at_least_one_of_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
describe '#validate!' do
let(:scope) do
Struct.new(:opts) do
def params(arg); end
def params(arg)
arg
end

def required?; end
end
end
let(:at_least_one_of_params) { [:beer, :wine, :grapefruit] }
Expand Down
6 changes: 5 additions & 1 deletion spec/grape/validations/validators/exactly_one_of_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
describe '#validate!' do
let(:scope) do
Struct.new(:opts) do
def params(arg); end
def params(arg)
arg
end

def required?; end
end
end
let(:exactly_one_of_params) { [:beer, :wine, :grapefruit] }
Expand Down
4 changes: 3 additions & 1 deletion spec/grape/validations/validators/mutual_exclusion_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
describe '#validate!' do
let(:scope) do
Struct.new(:opts) do
def params(arg); end
def params(arg)
arg
end
end
end
let(:mutually_exclusive_params) { [:beer, :wine, :grapefruit] }
Expand Down
97 changes: 92 additions & 5 deletions spec/grape/validations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -980,17 +980,24 @@ module SharedParams
optional :beer
optional :wine
mutually_exclusive :beer, :wine
optional :scotch
optional :aquavit
mutually_exclusive :scotch, :aquavit
optional :nested, type: Hash do
optional :scotch
optional :aquavit
mutually_exclusive :scotch, :aquavit
end
optional :nested2, type: Array do
optional :scotch2
optional :aquavit2
mutually_exclusive :scotch2, :aquavit2
end
end
subject.get '/mutually_exclusive' do
'mutually_exclusive works!'
end

get '/mutually_exclusive', beer: 'true', wine: 'true', scotch: 'true', aquavit: 'true'
get '/mutually_exclusive', beer: 'true', wine: 'true', nested: { scotch: 'true', aquavit: 'true' }, nested2: [{ scotch2: 'true' }, { scotch2: 'true', aquavit2: 'true' }]
expect(last_response.status).to eq(400)
expect(last_response.body).to eq "beer, wine are mutually exclusive, scotch, aquavit are mutually exclusive"
expect(last_response.body).to eq "beer, wine are mutually exclusive, scotch, aquavit are mutually exclusive, scotch2, aquavit2 are mutually exclusive"
end
end
end
Expand Down Expand Up @@ -1027,6 +1034,46 @@ module SharedParams
expect(last_response.body).to eq "beer, wine are mutually exclusive"
end
end

context 'nested params' do
before :each do
subject.params do
requires :nested, type: Hash do
optional :beer_nested
optional :wine_nested
optional :juice_nested
exactly_one_of :beer_nested, :wine_nested, :juice_nested
end
optional :nested2, type: Array do
optional :beer_nested2
optional :wine_nested2
optional :juice_nested2
exactly_one_of :beer_nested2, :wine_nested2, :juice_nested2
end
end
subject.get '/exactly_one_of_nested' do
'exactly_one_of works!'
end
end

it 'errors when none are present' do
get '/exactly_one_of_nested'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq "nested is missing, beer_nested, wine_nested, juice_nested are missing, exactly one parameter must be provided"
end

it 'succeeds when one is present' do
get '/exactly_one_of_nested', nested: { beer_nested: 'string' }
expect(last_response.status).to eq(200)
expect(last_response.body).to eq 'exactly_one_of works!'
end

it 'errors when two or more are present' do
get '/exactly_one_of_nested', nested: { beer_nested: 'string' }, nested2: [{ beer_nested2: 'string', wine_nested2: 'anotherstring' }]
expect(last_response.status).to eq(400)
expect(last_response.body).to eq "beer_nested2, wine_nested2 are mutually exclusive"
end
end
end

context 'at least one of' do
Expand Down Expand Up @@ -1061,6 +1108,46 @@ module SharedParams
expect(last_response.body).to eq 'at_least_one_of works!'
end
end

context 'nested params' do
before :each do
subject.params do
requires :nested, type: Hash do
optional :beer_nested
optional :wine_nested
optional :juice_nested
at_least_one_of :beer_nested, :wine_nested, :juice_nested
end
optional :nested2, type: Array do
optional :beer_nested2
optional :wine_nested2
optional :juice_nested2
at_least_one_of :beer_nested2, :wine_nested2, :juice_nested2
end
end
subject.get '/at_least_one_of_nested' do
'at_least_one_of works!'
end
end

it 'errors when none are present' do
get '/at_least_one_of_nested'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq "nested is missing, beer_nested, wine_nested, juice_nested are missing, at least one parameter must be provided"
end

it 'does not error when one is present' do
get '/at_least_one_of_nested', nested: { beer_nested: 'string' }, nested2: [{ beer_nested2: 'string' }]
expect(last_response.status).to eq(200)
expect(last_response.body).to eq 'at_least_one_of works!'
end

it 'does not error when two are present' do
get '/at_least_one_of_nested', nested: { beer_nested: 'string', wine_nested: 'string' }, nested2: [{ beer_nested2: 'string', wine_nested2: 'string' }]
expect(last_response.status).to eq(200)
expect(last_response.body).to eq 'at_least_one_of works!'
end
end
end
end
end