Skip to content

Commit 5dc4e5b

Browse files
author
Tim Connor
committed
Ensure complete declared params structure is present
1 parent 00e82b4 commit 5dc4e5b

File tree

5 files changed

+171
-70
lines changed

5 files changed

+171
-70
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#### Fixes
88

99
* Your contribution here.
10+
* [#2103](https://github.com/ruby-grape/grape/pull/2103): Ensure complete declared params structure is present - [@tlconnor](https://github.com/tlconnor).
1011
* [#2099](https://github.com/ruby-grape/grape/pull/2099): Added truffleruby to Travis-CI - [@gogainda](https://github.com/gogainda).
1112
* [#2089](https://github.com/ruby-grape/grape/pull/2089): Specify order of mounting Grape with Rack::Cascade in README - [@jonmchan](https://github.com/jonmchan).
1213
* [#2083](https://github.com/ruby-grape/grape/pull/2083): Set `Cache-Control` header only for streamed responses - [@stanhu](https://github.com/stanhu).

README.md

+48-5
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ use Rack::Session::Cookie
353353
run Rack::Cascade.new [Web, API]
354354
```
355355

356-
Note that order of loading apps using `Rack::Cascade` matters. The grape application must be last if you want to raise custom 404 errors from grape (such as `error!('Not Found',404)`). If the grape application is not last and returns 404 or 405 response, [cascade utilizes that as a signal to try the next app](https://www.rubydoc.info/gems/rack/Rack/Cascade). This may lead to undesirable behavior showing the [wrong 404 page from the wrong app](https://github.com/ruby-grape/grape/issues/1515).
356+
Note that order of loading apps using `Rack::Cascade` matters. The grape application must be last if you want to raise custom 404 errors from grape (such as `error!('Not Found',404)`). If the grape application is not last and returns 404 or 405 response, [cascade utilizes that as a signal to try the next app](https://www.rubydoc.info/gems/rack/Rack/Cascade). This may lead to undesirable behavior showing the [wrong 404 page from the wrong app](https://github.com/ruby-grape/grape/issues/1515).
357357

358358

359359
### Rails
@@ -787,7 +787,12 @@ Available parameter builders are `Grape::Extensions::Hash::ParamBuilder`, `Grape
787787

788788
### Declared
789789

790-
Grape allows you to access only the parameters that have been declared by your `params` block. It filters out the params that have been passed, but are not allowed. Consider the following API endpoint:
790+
Grape allows you to access only the parameters that have been declared by your `params` block. It will:
791+
792+
* Filter out the params that have been passed, but are not allowed.
793+
* Include any optional params that are declared but not passed.
794+
795+
Consider the following API endpoint:
791796

792797
````ruby
793798
format :json
@@ -820,9 +825,9 @@ Once we add parameters requirements, grape will start returning only the declare
820825
format :json
821826

822827
params do
823-
requires :user, type: Hash do
824-
requires :first_name, type: String
825-
requires :last_name, type: String
828+
optional :user, type: Hash do
829+
optional :first_name, type: String
830+
optional :last_name, type: String
826831
end
827832
end
828833

@@ -850,6 +855,44 @@ curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d
850855
}
851856
````
852857

858+
Missing params that are declared as type `Hash` or `Array` will be included.
859+
860+
````ruby
861+
format :json
862+
863+
params do
864+
optional :user, type: Hash do
865+
optional :first_name, type: String
866+
optional :last_name, type: String
867+
end
868+
optional :widgets, type: Array
869+
end
870+
871+
post 'users/signup' do
872+
{ 'declared_params' => declared(params) }
873+
end
874+
````
875+
876+
**Request**
877+
878+
````bash
879+
curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d '{}'
880+
````
881+
882+
**Response**
883+
884+
````json
885+
{
886+
"declared_params": {
887+
"user": {
888+
"first_name": null,
889+
"last_name": null
890+
},
891+
"widgets": []
892+
}
893+
}
894+
````
895+
853896
The returned hash is an `ActiveSupport::HashWithIndifferentAccess`.
854897

855898
The `#declared` method is not available to `before` filters, as those are evaluated prior to parameter coercion.

UPGRADING.md

+43-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
Upgrading Grape
22
===============
33

4+
### Upgrading to >= 1.4.1
5+
6+
Prior to 1.3.3, the `declared` helper would always return the complete params structure if `include_missing=true` was set. In 1.3.3 the behavior was changed, so a missing Hash with or without nested parameters would always resolve to `{}`.
7+
8+
In 1.4.1 this behavior is reverted, so the whole params structure will always be available via `declared`, regardless of whether any params are passed.
9+
10+
The following rules now apply to the `declared` helper when params are missing and `include_missing=true`:
11+
12+
* Hash params with children will resolve to a Hash with keys for each declared child.
13+
* Hash params with no children will resolve to `{}`.
14+
* Set params will resolve to `Set.new`.
15+
* Array params will resolve to `[]`.
16+
* All other params will resolve to `nil`.
17+
18+
#### Example
19+
20+
```ruby
21+
class Api < Grape::API
22+
params do
23+
optional :outer, type: Hash do
24+
optional :inner, type: Hash do
25+
optional :value, type: String
26+
end
27+
end
28+
end
29+
get 'example' do
30+
declared(params, include_missing: true)
31+
end
32+
end
33+
```
34+
35+
```
36+
get '/example'
37+
# 1.4.0 = {}
38+
# 1.4.1 = {outer: {inner: {value:null}}}
39+
```
40+
41+
For more information see [#2103](https://github.com/ruby-grape/grape/pull/2103).
42+
443
### Upgrading to >= 1.4.0
544

645
#### Reworking stream and file and un-deprecating stream like-objects
@@ -28,17 +67,17 @@ class API < Grape::API
2867
end
2968
```
3069

31-
Or use `stream` to stream other kinds of content. In the following example a streamer class
70+
Or use `stream` to stream other kinds of content. In the following example a streamer class
3271
streams paginated data from a database.
3372

3473
```ruby
35-
class MyObject
74+
class MyObject
3675
attr_accessor :result
3776

3877
def initialize(query)
3978
@result = query
4079
end
41-
80+
4281
def each
4382
yield '['
4483
# Do paginated DB fetches and return each page formatted
@@ -47,7 +86,7 @@ class MyObject
4786
yield process_records(records, first)
4887
first = false
4988
end
50-
yield ']'
89+
yield ']'
5190
end
5291

5392
def process_records(records, first)

lib/grape/dsl/inside_route.rb

+23-36
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def declared_hash(passed_params, options, declared_params, params_nested_path)
5858
passed_children_params = passed_params[declared_parent_param] || passed_params.class.new
5959
memo_key = optioned_param_key(declared_parent_param, options)
6060

61-
memo[memo_key] = handle_passed_param(passed_children_params, params_nested_path_dup) do
61+
memo[memo_key] = handle_passed_param(params_nested_path_dup, passed_children_params.any?) do
6262
declared(passed_children_params, options, declared_children_params, params_nested_path_dup)
6363
end
6464
end
@@ -70,57 +70,44 @@ def declared_hash(passed_params, options, declared_params, params_nested_path)
7070

7171
next unless options[:include_missing] || passed_params.key?(declared_param) || (param_renaming && passed_params.key?(param_renaming))
7272

73-
if param_renaming
74-
memo[optioned_param_key(param_renaming, options)] = passed_params[param_renaming]
75-
else
76-
memo[optioned_param_key(declared_param, options)] = passed_params[declared_param]
73+
memo_key = optioned_param_key(param_renaming || declared_param, options)
74+
passed_param = passed_params[param_renaming || declared_param]
75+
76+
params_nested_path_dup = params_nested_path.dup
77+
params_nested_path_dup << declared_param.to_s
78+
79+
memo[memo_key] = handle_passed_param(params_nested_path_dup) do
80+
passed_param
7781
end
7882
end
7983
end
8084
end
8185

82-
def handle_passed_param(passed_children_params, params_nested_path, &_block)
83-
if should_be_empty_hash?(passed_children_params, params_nested_path)
86+
def handle_passed_param(params_nested_path, has_passed_children = false, &_block)
87+
return yield if has_passed_children
88+
89+
key = params_nested_path[0]
90+
key += '[' + params_nested_path[1..-1].join('][') + ']' if params_nested_path.size > 1
91+
92+
route_options_params = options[:route_options][:params] || {}
93+
type = route_options_params.dig(key, :type)
94+
has_children = route_options_params.keys.any? { |k| k != key && k.start_with?(key) }
95+
96+
if type == 'Hash' && !has_children
8497
{}
85-
elsif should_be_empty_array?(passed_children_params, params_nested_path)
98+
elsif type == 'Array' || type&.start_with?('[')
8699
[]
100+
elsif type == 'Set' || type&.start_with?('#<Set')
101+
Set.new
87102
else
88103
yield
89104
end
90105
end
91106

92-
def should_be_empty_array?(passed_children_params, params_nested_path)
93-
passed_children_params.empty? && declared_param_is_array?(params_nested_path)
94-
end
95-
96-
def declared_param_is_array?(params_nested_path)
97-
key = route_options_params_key(params_nested_path)
98-
route_options_params[key] && route_options_params[key][:type] == 'Array'
99-
end
100-
101-
def should_be_empty_hash?(passed_children_params, params_nested_path)
102-
passed_children_params.empty? && declared_param_is_hash?(params_nested_path)
103-
end
104-
105-
def declared_param_is_hash?(params_nested_path)
106-
key = route_options_params_key(params_nested_path)
107-
route_options_params[key] && route_options_params[key][:type] == 'Hash'
108-
end
109-
110-
def route_options_params
111-
options[:route_options][:params] || {}
112-
end
113-
114107
def optioned_param_key(declared_param, options)
115108
options[:stringify] ? declared_param.to_s : declared_param.to_sym
116109
end
117110

118-
def route_options_params_key(params_nested_path)
119-
key = params_nested_path[0]
120-
key += '[' + params_nested_path[1..-1].join('][') + ']' if params_nested_path.size > 1
121-
key
122-
end
123-
124111
def optioned_declared_params(**options)
125112
declared_params = if options[:include_parent_namespaces]
126113
# Declared params including parent namespaces

spec/grape/endpoint/declared_spec.rb

+56-25
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,20 @@ def app
2828
optional :nested_arr, type: Array do
2929
optional :eighth
3030
end
31+
optional :empty_arr, type: Array
32+
optional :empty_typed_arr, type: Array[String]
33+
optional :empty_hash, type: Hash
34+
optional :empty_set, type: Set
35+
optional :empty_typed_set, type: Set[String]
3136
end
3237
optional :arr, type: Array do
3338
optional :nineth
3439
end
40+
optional :empty_arr, type: Array
41+
optional :empty_typed_arr, type: Array[String]
42+
optional :empty_hash, type: Hash
43+
optional :empty_set, type: Set
44+
optional :empty_typed_set, type: Set[String]
3545
end
3646
end
3747

@@ -103,7 +113,7 @@ def app
103113
end
104114
get '/declared?first=present'
105115
expect(last_response.status).to eq(200)
106-
expect(JSON.parse(last_response.body).keys.size).to eq(5)
116+
expect(JSON.parse(last_response.body).keys.size).to eq(10)
107117
end
108118

109119
it 'has a optional param with default value all the time' do
@@ -122,7 +132,7 @@ def app
122132

123133
get '/declared?first=present&nested[fourth]=1'
124134
expect(last_response.status).to eq(200)
125-
expect(JSON.parse(last_response.body)['nested'].keys.size).to eq 4
135+
expect(JSON.parse(last_response.body)['nested'].keys.size).to eq 9
126136
end
127137

128138
it 'builds nested params when given array' do
@@ -145,45 +155,66 @@ def app
145155
expect(JSON.parse(last_response.body)['nested'].size).to eq 2
146156
end
147157

148-
context 'sets nested objects when the param is missing' do
149-
it 'to be a hash when include_missing is true' do
150-
subject.get '/declared' do
151-
declared(params, include_missing: true)
152-
end
158+
context 'when the param is missing and include_missing=false' do
159+
before do
160+
subject.get('/declared') { declared(params, include_missing: false) }
161+
end
153162

163+
it 'sets nested objects to be nil' do
154164
get '/declared?first=present'
155165
expect(last_response.status).to eq(200)
156-
expect(JSON.parse(last_response.body)['nested']).to eq({})
166+
expect(JSON.parse(last_response.body)['nested']).to be_nil
157167
end
168+
end
158169

159-
it 'to be an array when include_missing is true' do
160-
subject.get '/declared' do
161-
declared(params, include_missing: true)
162-
end
170+
context 'when the param is missing and include_missing=true' do
171+
before do
172+
subject.get('/declared') { declared(params, include_missing: true) }
173+
end
163174

175+
it 'sets objects with type=Hash to be a hash' do
164176
get '/declared?first=present'
165177
expect(last_response.status).to eq(200)
166-
expect(JSON.parse(last_response.body)['arr']).to be_a(Array)
167-
end
168178

169-
it 'to be an array when nested and include_missing is true' do
170-
subject.get '/declared' do
171-
declared(params, include_missing: true)
172-
end
179+
body = JSON.parse(last_response.body)
180+
expect(body['empty_hash']).to eq({})
181+
expect(body['nested']).to be_a(Hash)
182+
expect(body['nested']['empty_hash']).to eq({})
183+
expect(body['nested']['nested_two']).to be_a(Hash)
184+
end
173185

174-
get '/declared?first=present&nested[fourth]=1'
186+
it 'sets objects with type=Set to be a set' do
187+
get '/declared?first=present'
175188
expect(last_response.status).to eq(200)
176-
expect(JSON.parse(last_response.body)['nested']['nested_arr']).to be_a(Array)
189+
190+
body = JSON.parse(last_response.body)
191+
expect(body['empty_set']).to eq('#<Set: {}>')
192+
expect(body['empty_typed_set']).to eq('#<Set: {}>')
193+
expect(body['nested']['empty_set']).to eq('#<Set: {}>')
194+
expect(body['nested']['empty_typed_set']).to eq('#<Set: {}>')
177195
end
178196

179-
it 'to be nil when include_missing is false' do
180-
subject.get '/declared' do
181-
declared(params, include_missing: false)
182-
end
197+
it 'sets objects with type=Array to be an array' do
198+
get '/declared?first=present'
199+
expect(last_response.status).to eq(200)
200+
201+
body = JSON.parse(last_response.body)
202+
expect(body['empty_arr']).to eq([])
203+
expect(body['empty_typed_arr']).to eq([])
204+
expect(body['arr']).to eq([])
205+
expect(body['nested']['empty_arr']).to eq([])
206+
expect(body['nested']['empty_typed_arr']).to eq([])
207+
expect(body['nested']['nested_arr']).to eq([])
208+
end
183209

210+
it 'includes all declared children when type=Hash' do
184211
get '/declared?first=present'
185212
expect(last_response.status).to eq(200)
186-
expect(JSON.parse(last_response.body)['nested']).to be_nil
213+
214+
body = JSON.parse(last_response.body)
215+
expect(body['nested'].keys).to eq(%w[fourth fifth nested_two nested_arr empty_arr empty_typed_arr empty_hash empty_set empty_typed_set])
216+
expect(body['nested']['nested_two'].keys).to eq(%w[sixth nested_three])
217+
expect(body['nested']['nested_two']['nested_three'].keys).to eq(%w[seventh])
187218
end
188219
end
189220

0 commit comments

Comments
 (0)