Skip to content

Commit 0c424d2

Browse files
authored
Implement nested with support in parameter DSL (#2434)
1 parent 5cc85c3 commit 0c424d2

File tree

6 files changed

+155
-1
lines changed

6 files changed

+155
-1
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* [#2431](https://github.com/ruby-grape/grape/pull/2431): Drop appraisals in favor of eval_gemfile - [@ericproulx](https://github.com/ericproulx).
2828
* [#2435](https://github.com/ruby-grape/grape/pull/2435): Use rack constants - [@ericproulx](https://github.com/ericproulx).
2929
* [#2436](https://github.com/ruby-grape/grape/pull/2436): Update coverallsapp github-action - [@ericproulx](https://github.com/ericproulx).
30+
* [#2434](https://github.com/ruby-grape/grape/pull/2434): Implement nested `with` support in parameter dsl - [@numbata](https://github.com/numbata).
3031
* Your contribution here.
3132

3233
#### Fixes

README.md

+14
Original file line numberDiff line numberDiff line change
@@ -1567,6 +1567,20 @@ params do
15671567
end
15681568
```
15691569

1570+
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.
1571+
1572+
```ruby
1573+
params do
1574+
with(documentation: { in: 'body' }) do # Applies documentation to all nested parameters
1575+
with(type: String, regexp: /\w+/) do # Applies type and validation to names
1576+
requires :first_name, desc: 'First name'
1577+
requires :last_name, desc: 'Last name'
1578+
end
1579+
optional :age, type: Integer, desc: 'Age', documentation: { x: { nullable: true } } # Specific settings for 'age'
1580+
end
1581+
end
1582+
```
1583+
15701584
### Renaming
15711585

15721586
You can rename parameters using `as`, which can be useful when refactoring existing APIs:

lib/grape/dsl/parameters.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ def optional(*attrs, &block)
170170
# @param (see #requires)
171171
# @option (see #requires)
172172
def with(*attrs, &block)
173-
new_group_scope(attrs.clone, &block)
173+
new_group_attrs = [@group, attrs.clone.first].compact.reduce(&:deep_merge)
174+
new_group_scope([new_group_attrs], &block)
174175
end
175176

176177
# Disallow the given parameters to be present in the same request.

lib/grape/validations/params_scope.rb

+1
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ def new_scope(attrs, optional = false, &block)
264264
parent: self,
265265
optional: optional,
266266
type: type || Array,
267+
group: @group,
267268
&block
268269
)
269270
end

spec/grape/dsl/parameters_spec.rb

+47
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,17 @@ def validates_reader
3535
@validates
3636
end
3737

38+
def new_scope(args, _, &block)
39+
nested_scope = self.class.new
40+
nested_scope.new_group_scope(args, &block)
41+
nested_scope
42+
end
43+
3844
def new_group_scope(args)
45+
prev_group = @group
3946
@group = args.clone.first
4047
yield
48+
@group = prev_group
4149
end
4250

4351
def extract_message_option(attrs)
@@ -169,6 +177,45 @@ def extract_message_option(attrs)
169177
]
170178
)
171179
end
180+
181+
it "supports nested 'with' calls" do
182+
subject.with(type: Integer, documentation: { in: 'body' }) do
183+
subject.optional :pipboy_id
184+
subject.with(documentation: { default: 33 }) do
185+
subject.optional :vault
186+
subject.with(type: String) do
187+
subject.with(documentation: { default: 'resident' }) do
188+
subject.optional :role
189+
end
190+
end
191+
subject.optional :age, documentation: { default: 42 }
192+
end
193+
end
194+
195+
expect(subject.validate_attributes_reader).to eq(
196+
[
197+
[:pipboy_id], { type: Integer, documentation: { in: 'body' } },
198+
[:vault], { type: Integer, documentation: { in: 'body', default: 33 } },
199+
[:role], { type: String, documentation: { in: 'body', default: 'resident' } },
200+
[:age], { type: Integer, documentation: { in: 'body', default: 42 } }
201+
]
202+
)
203+
end
204+
205+
it "supports Hash parameter inside the 'with' calls" do
206+
subject.with(documentation: { in: 'body' }) do
207+
subject.optional :info, type: Hash, documentation: { x: { nullable: true }, desc: 'The info' } do
208+
subject.optional :vault, type: Integer, documentation: { default: 33, desc: 'The vault number' }
209+
end
210+
end
211+
212+
expect(subject.validate_attributes_reader).to eq(
213+
[
214+
[:info], { type: Hash, documentation: { in: 'body', desc: 'The info', x: { nullable: true } } },
215+
[:vault], { type: Integer, documentation: { in: 'body', default: 33, desc: 'The vault number' } }
216+
]
217+
)
218+
end
172219
end
173220

174221
describe '#mutually_exclusive' do

spec/grape/validations/params_scope_spec.rb

+90
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,96 @@ def initialize(value)
13811381
end
13821382
end
13831383
end
1384+
1385+
context 'with many levels of nested groups' do
1386+
before do
1387+
subject.params do
1388+
requires :first_level, type: Hash do
1389+
with(type: Integer) do
1390+
requires :value
1391+
with(type: String) do
1392+
optional :second_level, type: Array do
1393+
optional :name, type: String
1394+
with(type: Integer) do
1395+
optional :third_level, type: Array do
1396+
requires :value, type: Integer
1397+
optional :position
1398+
end
1399+
end
1400+
end
1401+
end
1402+
requires :id
1403+
end
1404+
end
1405+
end
1406+
subject.put('/nested') { declared(params).to_json }
1407+
end
1408+
1409+
context 'when data is valid' do
1410+
let(:request_params) do
1411+
{
1412+
first_level: {
1413+
value: '10',
1414+
second_level: [
1415+
{
1416+
name: '13',
1417+
third_level: [
1418+
{
1419+
value: '2',
1420+
position: '1'
1421+
}
1422+
]
1423+
}
1424+
],
1425+
id: '20'
1426+
}
1427+
}
1428+
end
1429+
1430+
it 'validates and coerces correctly' do
1431+
put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json'
1432+
1433+
expect(last_response.status).to eq(200)
1434+
expect(JSON.parse(last_response.body, symbolize_names: true)).to eq(
1435+
first_level: {
1436+
value: 10,
1437+
second_level: [
1438+
{ name: '13', third_level: [{ value: 2, position: 1 }] }
1439+
],
1440+
id: 20
1441+
}
1442+
)
1443+
end
1444+
end
1445+
1446+
context 'when data is invalid' do
1447+
let(:request_params) do
1448+
{
1449+
first_level: {
1450+
value: 'wrong',
1451+
second_level: [
1452+
{ name: 'name', third_level: [{ position: 'wrong' }] }
1453+
]
1454+
}
1455+
}
1456+
end
1457+
1458+
it 'responds with HTTP error' do
1459+
put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json'
1460+
expect(last_response.status).to eq(400)
1461+
end
1462+
1463+
it 'responds with a validation error' do
1464+
put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json'
1465+
1466+
expect(last_response.body)
1467+
.to include('first_level[value] is invalid')
1468+
.and include('first_level[id] is missing')
1469+
.and include('first_level[second_level][0][third_level][0][value] is missing')
1470+
.and include('first_level[second_level][0][third_level][0][position] is invalid')
1471+
end
1472+
end
1473+
end
13841474
end
13851475

13861476
context 'with exactly_one_of validation for optional parameters within an Hash param' do

0 commit comments

Comments
 (0)