Skip to content

Commit

Permalink
Adds #given to DSL::Parameters, allowing for dependent params.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rnubel committed Jun 26, 2015
1 parent b474f5e commit f7e424e
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 9 deletions.
1 change: 1 addition & 0 deletions lib/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ module Exceptions
autoload :InvalidVersionerOption
autoload :UnknownValidator
autoload :UnknownOptions
autoload :UnknownParameter
autoload :InvalidWithOptionForRepresent
autoload :IncompatibleOptionValues
autoload :MissingGroupTypeError, 'grape/exceptions/missing_group_type'
Expand Down
21 changes: 21 additions & 0 deletions lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,27 @@ def all_or_none_of(*attrs)
validates(attrs, all_or_none_of: true)
end

# Define a block of validations which should be applied if and only if
# the given parameter is present. The parameters are not nested.
# @param attr [Symbol] the parameter which, if present, triggers the
# validations
# @throws Grape::Exceptions::UnknownParameter if `attr` has not been
# defined in this scope yet
# @yield a parameter definition DSL
def given(attr, &block)
fail Grape::Exceptions::UnknownParameter.new(attr) unless declared_param?(attr)
new_lateral_scope(dependent_on: attr, &block)
end

# Test for whether a certain parameter has been defined in this params
# block yet.
# @returns [Boolean] whether the parameter has been defined
def declared_param?(param)
# @declared_params also includes hashes of options and such, but those
# won't be flattened out.
@declared_params.flatten.include?(param)
end

alias_method :group, :requires

# @param params [Hash] initial hash of parameters
Expand Down
10 changes: 10 additions & 0 deletions lib/grape/exceptions/unknown_parameter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# encoding: utf-8
module Grape
module Exceptions
class UnknownParameter < Base
def initialize(param)
super(message: compose_message('unknown_parameter', param: param))
end
end
end
end
1 change: 1 addition & 0 deletions lib/grape/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ en:
resolution: 'available strategy for :using is :path, :header, :param'
unknown_validator: 'unknown validator: %{validator_type}'
unknown_options: 'unknown options: %{options}'
unknown_parameter: 'unknown parameter: %{param}'
incompatible_option_values: '%{option1}: %{value1} is incompatible with %{option2}: %{value2}'
mutual_exclusion: 'are mutually exclusive'
at_least_one: 'are missing, at least one parameter must be provided'
Expand Down
57 changes: 48 additions & 9 deletions lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ class ParamsScope
# @option opts :optional [Boolean] whether or not this scope needs to have
# any parameters set or not
# @option opts :type [Class] a type meant to govern this scope (deprecated)
# @option opts :dependent_on [Symbol] if present, this scope should only
# validate if this param is present in the parent scope
# @yield the instance context, open for parameter definitions
def initialize(opts, &block)
@element = opts[:element]
@parent = opts[:parent]
@api = opts[:api]
@optional = opts[:optional] || false
@type = opts[:type]
@element = opts[:element]
@parent = opts[:parent]
@api = opts[:api]
@optional = opts[:optional] || false
@type = opts[:type]
@dependent_on = opts[:dependent_on]
@declared_params = []

instance_eval(&block) if block_given?
Expand All @@ -33,14 +36,25 @@ def initialize(opts, &block)
# validated
def should_validate?(parameters)
return false if @optional && params(parameters).respond_to?(:all?) && params(parameters).all?(&:blank?)
return false if @dependent_on && params(parameters).try(:[], @dependent_on).blank?
return true if parent.nil?
parent.should_validate?(parameters)
end

# @return [String] the proper attribute name, with nesting considered.
def full_name(name)
return "#{@parent.full_name(@element)}[#{name}]" if @parent
name.to_s
case
when @parent && @element
# Nested scope. Find our containing element's name, and append ours.
"#{@parent.full_name(@element)}[#{name}]"
when @parent
# Lateral scope. Find the name of the element as if it was at the
# same nesting level as our parent.
@parent.full_name(name)
else
# We must be the root scope, so no prefix needed.
name.to_s
end
end

# @return [Boolean] whether or not this scope is the root-level scope
Expand All @@ -57,7 +71,7 @@ def required?
protected

# Adds a parameter declaration to our list of validations.
# @param attrs [Array] (see Grape::DSL::Parameters#required)
# @param attrs [Array] (see Grape::DSL::Parameters#requires)
def push_declared_params(attrs)
@declared_params.concat attrs
end
Expand Down Expand Up @@ -98,6 +112,13 @@ def validate_attributes(attrs, opts, &block)
validates(attrs, validations)
end

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

opts = attrs[1] || { type: Array }
ParamsScope.new(api: @api, element: attrs.first, parent: self, optional: optional, type: opts[:type], &block)
self.class.new(api: @api, element: attrs.first, parent: self, optional: optional, type: opts[:type], &block)
end

# Returns a new parameter scope, not nested under any current-level param
# but instead at the same level as the current scope.
# @param options [Hash] options to control how this new scope behaves
# @option options :dependent_on [Symbol] if given, specifies that this
# scope should only validate if this parameter from the above scope is
# present
# @yield parameter scope
def new_lateral_scope(options, &block)
self.class.new(
api: @api,
element: nil,
parent: self,
options: @optional,
type: Hash,
dependent_on: options[:dependent_on],
&block)
end

# Pushes declared params to parent or settings
Expand Down
49 changes: 49 additions & 0 deletions spec/grape/validations/params_scope_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,53 @@ def initialize(value)
end.to raise_error Grape::Exceptions::UnsupportedGroupTypeError
end
end

context 'when validations are dependent on a parameter' do
before do
subject.params do
optional :a
given :a do
requires :b
end
end
subject.get('/test') { 'worked' }
end

it 'applies the validations only if the parameter is present' do
get '/test'
expect(last_response.status).to eq(200)

get '/test', a: true
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('b is missing')

get '/test', a: true, b: true
expect(last_response.status).to eq(200)
end

it 'raises an error if the dependent parameter was never specified' do
expect do
subject.params do
given :c do
end
end
end.to raise_error(Grape::Exceptions::UnknownParameter)
end

it 'returns a sensible error message within a nested context' do
subject.params do
requires :bar, type: Hash do
optional :a
given :a do
requires :b
end
end
end
subject.get('/nested') { 'worked' }

get '/nested', bar: { a: true }
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('bar[b] is missing')
end
end
end

0 comments on commit f7e424e

Please sign in to comment.