Skip to content

Commit 2a541d5

Browse files
rnubelRobert Nubel
authored andcommitted
Adds #given to DSL::Parameters, allowing for dependent params.
Usage: # ... params do optional :shelf_id, type: Integer given :shelf_id do requires :bin_id, type: Integer end end This implements #958. In order to achieve the DSL-style implementation, I introduced the concept of a "lateral scope" as opposed to the "nested scope" which `requires :foo do` opens up. A lateral scope is subordinate to its parent, but not nested under an element.
1 parent b8b6053 commit 2a541d5

File tree

8 files changed

+167
-10
lines changed

8 files changed

+167
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Next Release
44
#### Features
55

66
* [#1039](https://github.com/intridea/grape/pull/1039): Added support for custom parameter types - [@rnubel](https://github.com/rnubel).
7+
* [#1047](https://github.com/intridea/grape/pull/1047): Adds `given` to DSL::Parameters, allowing for dependent params - [@rnubel](https://github.com/rnubel).
78
* Your contribution here!
89

910
#### Fixes

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- [Parameter Validation and Coercion](#parameter-validation-and-coercion)
3232
- [Supported Parameter Types](#supported-parameter-types)
3333
- [Custom Types](#custom-types)
34+
- [Dependent Parameters](#dependent-parameters)
3435
- [Built-in Validators](#built-in-validators)
3536
- [Namespace Validation and Coercion](#namespace-validation-and-coercion)
3637
- [Custom Validators](#custom-validators)
@@ -803,6 +804,21 @@ params do
803804
end
804805
```
805806

807+
#### Dependent Parameters
808+
809+
Suppose some of your parameters are only relevant if another parameter is given;
810+
Grape allows you to express this relationship through the `given` method in your
811+
parameters block, like so:
812+
813+
```ruby
814+
params do
815+
optional :shelf_id, type: Integer
816+
given :shelf_id do
817+
requires :bin_id, type: Integer
818+
end
819+
end
820+
```
821+
806822
### Built-in Validators
807823

808824
#### `allow_blank`

lib/grape.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ module Exceptions
6666
autoload :InvalidVersionerOption
6767
autoload :UnknownValidator
6868
autoload :UnknownOptions
69+
autoload :UnknownParameter
6970
autoload :InvalidWithOptionForRepresent
7071
autoload :IncompatibleOptionValues
7172
autoload :MissingGroupTypeError, 'grape/exceptions/missing_group_type'

lib/grape/dsl/parameters.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,27 @@ def all_or_none_of(*attrs)
149149
validates(attrs, all_or_none_of: true)
150150
end
151151

152+
# Define a block of validations which should be applied if and only if
153+
# the given parameter is present. The parameters are not nested.
154+
# @param attr [Symbol] the parameter which, if present, triggers the
155+
# validations
156+
# @throws Grape::Exceptions::UnknownParameter if `attr` has not been
157+
# defined in this scope yet
158+
# @yield a parameter definition DSL
159+
def given(attr, &block)
160+
fail Grape::Exceptions::UnknownParameter.new(attr) unless declared_param?(attr)
161+
new_lateral_scope(dependent_on: attr, &block)
162+
end
163+
164+
# Test for whether a certain parameter has been defined in this params
165+
# block yet.
166+
# @returns [Boolean] whether the parameter has been defined
167+
def declared_param?(param)
168+
# @declared_params also includes hashes of options and such, but those
169+
# won't be flattened out.
170+
@declared_params.flatten.include?(param)
171+
end
172+
152173
alias_method :group, :requires
153174

154175
# @param params [Hash] initial hash of parameters
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# encoding: utf-8
2+
module Grape
3+
module Exceptions
4+
class UnknownParameter < Base
5+
def initialize(param)
6+
super(message: compose_message('unknown_parameter', param: param))
7+
end
8+
end
9+
end
10+
end

lib/grape/locale/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ en:
2828
resolution: 'available strategy for :using is :path, :header, :param'
2929
unknown_validator: 'unknown validator: %{validator_type}'
3030
unknown_options: 'unknown options: %{options}'
31+
unknown_parameter: 'unknown parameter: %{param}'
3132
incompatible_option_values: '%{option1}: %{value1} is incompatible with %{option2}: %{value2}'
3233
mutual_exclusion: 'are mutually exclusive'
3334
at_least_one: 'are missing, at least one parameter must be provided'

lib/grape/validations/params_scope.rb

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ class ParamsScope
1515
# @option opts :optional [Boolean] whether or not this scope needs to have
1616
# any parameters set or not
1717
# @option opts :type [Class] a type meant to govern this scope (deprecated)
18+
# @option opts :dependent_on [Symbol] if present, this scope should only
19+
# validate if this param is present in the parent scope
1820
# @yield the instance context, open for parameter definitions
1921
def initialize(opts, &block)
20-
@element = opts[:element]
21-
@parent = opts[:parent]
22-
@api = opts[:api]
23-
@optional = opts[:optional] || false
24-
@type = opts[:type]
22+
@element = opts[:element]
23+
@parent = opts[:parent]
24+
@api = opts[:api]
25+
@optional = opts[:optional] || false
26+
@type = opts[:type]
27+
@dependent_on = opts[:dependent_on]
2528
@declared_params = []
2629

2730
instance_eval(&block) if block_given?
@@ -33,21 +36,45 @@ def initialize(opts, &block)
3336
# validated
3437
def should_validate?(parameters)
3538
return false if @optional && params(parameters).respond_to?(:all?) && params(parameters).all?(&:blank?)
39+
return false if @dependent_on && params(parameters).try(:[], @dependent_on).blank?
3640
return true if parent.nil?
3741
parent.should_validate?(parameters)
3842
end
3943

4044
# @return [String] the proper attribute name, with nesting considered.
4145
def full_name(name)
42-
return "#{@parent.full_name(@element)}[#{name}]" if @parent
43-
name.to_s
46+
case
47+
when nested?
48+
# Find our containing element's name, and append ours.
49+
"#{@parent.full_name(@element)}[#{name}]"
50+
when lateral?
51+
# Find the name of the element as if it was at the
52+
# same nesting level as our parent.
53+
@parent.full_name(name)
54+
else
55+
# We must be the root scope, so no prefix needed.
56+
name.to_s
57+
end
4458
end
4559

4660
# @return [Boolean] whether or not this scope is the root-level scope
4761
def root?
4862
!@parent
4963
end
5064

65+
# A nested scope is contained in one of its parent's elements.
66+
# @return [Boolean] whether or not this scope is nested
67+
def nested?
68+
@parent && @element
69+
end
70+
71+
# A lateral scope is subordinate to its parent, but its keys are at the
72+
# same level as its parent and thus is not contained within an element.
73+
# @return [Boolean] whether or not this scope is lateral
74+
def lateral?
75+
@parent && !@element
76+
end
77+
5178
# @return [Boolean] whether or not this scope needs to be present, or can
5279
# be blank
5380
def required?
@@ -57,7 +84,7 @@ def required?
5784
protected
5885

5986
# Adds a parameter declaration to our list of validations.
60-
# @param attrs [Array] (see Grape::DSL::Parameters#required)
87+
# @param attrs [Array] (see Grape::DSL::Parameters#requires)
6188
def push_declared_params(attrs)
6289
@declared_params.concat attrs
6390
end
@@ -98,6 +125,13 @@ def validate_attributes(attrs, opts, &block)
98125
validates(attrs, validations)
99126
end
100127

128+
# Returns a new parameter scope, subordinate to the current one and nested
129+
# under the parameter corresponding to `attrs.first`.
130+
# @param attrs [Array] the attributes passed to the `requires` or
131+
# `optional` invocation that opened this scope.
132+
# @param optional [Boolean] whether the parameter this are nested under
133+
# is optional or not (and hence, whether this block's params will be).
134+
# @yield parameter scope
101135
def new_scope(attrs, optional = false, &block)
102136
# if required params are grouped and no type or unsupported type is provided, raise an error
103137
type = attrs[1] ? attrs[1][:type] : nil
@@ -107,12 +141,30 @@ def new_scope(attrs, optional = false, &block)
107141
end
108142

109143
opts = attrs[1] || { type: Array }
110-
ParamsScope.new(api: @api, element: attrs.first, parent: self, optional: optional, type: opts[:type], &block)
144+
self.class.new(api: @api, element: attrs.first, parent: self, optional: optional, type: opts[:type], &block)
145+
end
146+
147+
# Returns a new parameter scope, not nested under any current-level param
148+
# but instead at the same level as the current scope.
149+
# @param options [Hash] options to control how this new scope behaves
150+
# @option options :dependent_on [Symbol] if given, specifies that this
151+
# scope should only validate if this parameter from the above scope is
152+
# present
153+
# @yield parameter scope
154+
def new_lateral_scope(options, &block)
155+
self.class.new(
156+
api: @api,
157+
element: nil,
158+
parent: self,
159+
options: @optional,
160+
type: Hash,
161+
dependent_on: options[:dependent_on],
162+
&block)
111163
end
112164

113165
# Pushes declared params to parent or settings
114166
def configure_declared_params
115-
if @parent
167+
if nested?
116168
@parent.push_declared_params [element => @declared_params]
117169
else
118170
@api.namespace_stackable(:declared_params, @declared_params)

spec/grape/validations/params_scope_spec.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,59 @@ def initialize(value)
271271
end.to raise_error Grape::Exceptions::UnsupportedGroupTypeError
272272
end
273273
end
274+
275+
context 'when validations are dependent on a parameter' do
276+
before do
277+
subject.params do
278+
optional :a
279+
given :a do
280+
requires :b
281+
end
282+
end
283+
subject.get('/test') { declared(params).to_json }
284+
end
285+
286+
it 'applies the validations only if the parameter is present' do
287+
get '/test'
288+
expect(last_response.status).to eq(200)
289+
290+
get '/test', a: true
291+
expect(last_response.status).to eq(400)
292+
expect(last_response.body).to eq('b is missing')
293+
294+
get '/test', a: true, b: true
295+
expect(last_response.status).to eq(200)
296+
end
297+
298+
it 'raises an error if the dependent parameter was never specified' do
299+
expect do
300+
subject.params do
301+
given :c do
302+
end
303+
end
304+
end.to raise_error(Grape::Exceptions::UnknownParameter)
305+
end
306+
307+
it 'includes the parameter within #declared(params)' do
308+
get '/test', a: true, b: true
309+
310+
expect(JSON.parse(last_response.body)).to eq('a' => 'true', 'b' => 'true')
311+
end
312+
313+
it 'returns a sensible error message within a nested context' do
314+
subject.params do
315+
requires :bar, type: Hash do
316+
optional :a
317+
given :a do
318+
requires :b
319+
end
320+
end
321+
end
322+
subject.get('/nested') { 'worked' }
323+
324+
get '/nested', bar: { a: true }
325+
expect(last_response.status).to eq(400)
326+
expect(last_response.body).to eq('bar[b] is missing')
327+
end
328+
end
274329
end

0 commit comments

Comments
 (0)