diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3c30de01..7ef056f65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * [#2431](https://github.com/ruby-grape/grape/pull/2431): Drop appraisals in favor of eval_gemfile - [@ericproulx](https://github.com/ericproulx). * [#2435](https://github.com/ruby-grape/grape/pull/2435): Use rack constants - [@ericproulx](https://github.com/ericproulx). * [#2436](https://github.com/ruby-grape/grape/pull/2436): Update coverallsapp github-action - [@ericproulx](https://github.com/ericproulx). +* [#2434](https://github.com/ruby-grape/grape/pull/2434): Implement nested `with` support in parameter dsl - [@numbata](https://github.com/numbata). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index ea2a964439..e346aa53d3 100644 --- a/README.md +++ b/README.md @@ -1567,6 +1567,20 @@ params do end ``` +You can organize settings into layers using nested `with' blocks. Each layer can use, add to, or change the settings of the layer above it. This helps to keep complex parameters organized and consistent, while still allowing for specific customizations to be made. + +```ruby +params do + with(documentation: { in: 'body' }) do # Applies documentation to all nested parameters + with(type: String, regexp: /\w+/) do # Applies type and validation to names + requires :first_name, desc: 'First name' + requires :last_name, desc: 'Last name' + end + optional :age, type: Integer, desc: 'Age', documentation: { x: { nullable: true } } # Specific settings for 'age' + end +end +``` + ### Renaming You can rename parameters using `as`, which can be useful when refactoring existing APIs: diff --git a/lib/grape/dsl/parameters.rb b/lib/grape/dsl/parameters.rb index e774105a8a..bfc9b408e3 100644 --- a/lib/grape/dsl/parameters.rb +++ b/lib/grape/dsl/parameters.rb @@ -170,7 +170,8 @@ def optional(*attrs, &block) # @param (see #requires) # @option (see #requires) def with(*attrs, &block) - new_group_scope(attrs.clone, &block) + new_group_attrs = [@group, attrs.clone.first].compact.reduce(&:deep_merge) + new_group_scope([new_group_attrs], &block) end # Disallow the given parameters to be present in the same request. diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index 026e7df9ce..9e1e28b84f 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -264,6 +264,7 @@ def new_scope(attrs, optional = false, &block) parent: self, optional: optional, type: type || Array, + group: @group, &block ) end diff --git a/spec/grape/dsl/parameters_spec.rb b/spec/grape/dsl/parameters_spec.rb index 5106866d09..97aae0a586 100644 --- a/spec/grape/dsl/parameters_spec.rb +++ b/spec/grape/dsl/parameters_spec.rb @@ -35,9 +35,17 @@ def validates_reader @validates end + def new_scope(args, _, &block) + nested_scope = self.class.new + nested_scope.new_group_scope(args, &block) + nested_scope + end + def new_group_scope(args) + prev_group = @group @group = args.clone.first yield + @group = prev_group end def extract_message_option(attrs) @@ -169,6 +177,45 @@ def extract_message_option(attrs) ] ) end + + it "supports nested 'with' calls" do + subject.with(type: Integer, documentation: { in: 'body' }) do + subject.optional :pipboy_id + subject.with(documentation: { default: 33 }) do + subject.optional :vault + subject.with(type: String) do + subject.with(documentation: { default: 'resident' }) do + subject.optional :role + end + end + subject.optional :age, documentation: { default: 42 } + end + end + + expect(subject.validate_attributes_reader).to eq( + [ + [:pipboy_id], { type: Integer, documentation: { in: 'body' } }, + [:vault], { type: Integer, documentation: { in: 'body', default: 33 } }, + [:role], { type: String, documentation: { in: 'body', default: 'resident' } }, + [:age], { type: Integer, documentation: { in: 'body', default: 42 } } + ] + ) + end + + it "supports Hash parameter inside the 'with' calls" do + subject.with(documentation: { in: 'body' }) do + subject.optional :info, type: Hash, documentation: { x: { nullable: true }, desc: 'The info' } do + subject.optional :vault, type: Integer, documentation: { default: 33, desc: 'The vault number' } + end + end + + expect(subject.validate_attributes_reader).to eq( + [ + [:info], { type: Hash, documentation: { in: 'body', desc: 'The info', x: { nullable: true } } }, + [:vault], { type: Integer, documentation: { in: 'body', default: 33, desc: 'The vault number' } } + ] + ) + end end describe '#mutually_exclusive' do diff --git a/spec/grape/validations/params_scope_spec.rb b/spec/grape/validations/params_scope_spec.rb index 9ef7ad7db8..af6a4a2c93 100644 --- a/spec/grape/validations/params_scope_spec.rb +++ b/spec/grape/validations/params_scope_spec.rb @@ -1381,6 +1381,96 @@ def initialize(value) end end end + + context 'with many levels of nested groups' do + before do + subject.params do + requires :first_level, type: Hash do + with(type: Integer) do + requires :value + with(type: String) do + optional :second_level, type: Array do + optional :name, type: String + with(type: Integer) do + optional :third_level, type: Array do + requires :value, type: Integer + optional :position + end + end + end + end + requires :id + end + end + end + subject.put('/nested') { declared(params).to_json } + end + + context 'when data is valid' do + let(:request_params) do + { + first_level: { + value: '10', + second_level: [ + { + name: '13', + third_level: [ + { + value: '2', + position: '1' + } + ] + } + ], + id: '20' + } + } + end + + it 'validates and coerces correctly' do + put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body, symbolize_names: true)).to eq( + first_level: { + value: 10, + second_level: [ + { name: '13', third_level: [{ value: 2, position: 1 }] } + ], + id: 20 + } + ) + end + end + + context 'when data is invalid' do + let(:request_params) do + { + first_level: { + value: 'wrong', + second_level: [ + { name: 'name', third_level: [{ position: 'wrong' }] } + ] + } + } + end + + it 'responds with HTTP error' do + put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'responds with a validation error' do + put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json' + + expect(last_response.body) + .to include('first_level[value] is invalid') + .and include('first_level[id] is missing') + .and include('first_level[second_level][0][third_level][0][value] is missing') + .and include('first_level[second_level][0][third_level][0][position] is invalid') + end + end + end end context 'with exactly_one_of validation for optional parameters within an Hash param' do