Skip to content

Commit 2691703

Browse files
committed
Support bracket-style OpenAPI 3 query params
1 parent 7daefdc commit 2691703

2 files changed

Lines changed: 141 additions & 0 deletions

File tree

lib/committee/schema_validator/open_api_3/parameter_deserializer.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def deserialize_params_by_location(raw_params, location)
5252
# If no parameters are defined for this location, return raw params as-is
5353
return raw_params if params_for_location.empty?
5454

55+
raw_params = normalize_raw_params(raw_params, location, params_for_location)
56+
5557
# Collect parameter names that will be deserialized
5658
# This includes both the parameter name and any properties (for exploded objects)
5759
deserialized_keys = Set.new
@@ -105,6 +107,65 @@ def convert_to_indifferent_hash(hash)
105107
Committee::Utils.indifferent_hash.merge(hash)
106108
end
107109

110+
# Normalize Rack-style nested query hashes into bracket notation when the
111+
# schema expects bracket-named params or deepObject query params.
112+
# Example: { "filter" => { "slug" => "/test" } } => { "filter[slug]" => "/test" }
113+
# @param [Hash] raw_params
114+
# @param [String] location
115+
# @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
116+
# @return [Hash]
117+
def normalize_raw_params(raw_params, location, params_for_location)
118+
return raw_params unless location == 'query'
119+
return raw_params unless raw_params.values.any? { |value| value.is_a?(Hash) }
120+
return raw_params unless requires_query_param_flattening?(params_for_location)
121+
122+
normalized = Committee::Utils.indifferent_hash
123+
124+
raw_params.each do |key, value|
125+
if should_flatten_query_param?(key, value, params_for_location)
126+
flatten_nested_query_param(normalized, key.to_s, value)
127+
else
128+
normalized[key] = value
129+
end
130+
end
131+
132+
normalized
133+
end
134+
135+
# @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
136+
# @return [Boolean]
137+
def requires_query_param_flattening?(params_for_location)
138+
params_for_location.any? { |param_def| param_def.style == 'deepObject' || param_def.name.include?('[') }
139+
end
140+
141+
# @param [String, Symbol] key
142+
# @param [Object] value
143+
# @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
144+
# @return [Boolean]
145+
def should_flatten_query_param?(key, value, params_for_location)
146+
return false unless value.is_a?(Hash)
147+
148+
key_name = key.to_s
149+
params_for_location.any? do |param_def|
150+
param_def.name == key_name || param_def.name.start_with?("#{key_name}[")
151+
end
152+
end
153+
154+
# @param [Hash] result
155+
# @param [String] prefix
156+
# @param [Object] value
157+
# @return [void]
158+
def flatten_nested_query_param(result, prefix, value)
159+
case value
160+
when Hash
161+
value.each do |child_key, child_value|
162+
flatten_nested_query_param(result, "#{prefix}[#{child_key}]", child_value)
163+
end
164+
else
165+
result[prefix] = value
166+
end
167+
end
168+
108169
# Extract and deserialize a single parameter
109170
# @param [OpenAPIParser::Schemas::Parameter] param_def Parameter definition
110171
# @param [Hash] raw_params Raw parameters

test/middleware/request_validation_open_api_3_test.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,67 @@ def app
660660
end
661661
end
662662

663+
describe 'bracket-style query params' do
664+
it 'validates query params declared with bracket notation names' do
665+
check_parameter = lambda { |env|
666+
assert_equal '/test', env['committee.query_hash']['filter[slug]']
667+
refute env['committee.query_hash'].key?('filter')
668+
[200, {}, []]
669+
}
670+
671+
@app = new_rack_app_with_lambda(check_parameter, schema: query_param_schema({
672+
'name' => 'filter[slug]',
673+
'in' => 'query',
674+
'required' => true,
675+
'schema' => { 'type' => 'string' },
676+
}))
677+
678+
get '/events?filter[slug]=%2Ftest'
679+
680+
assert_equal 200, last_response.status
681+
end
682+
683+
it 'rejects unknown nested query params with strict_query_params' do
684+
@app = new_rack_app(schema: query_param_schema({
685+
'name' => 'filter[slug]',
686+
'in' => 'query',
687+
'required' => true,
688+
'schema' => { 'type' => 'string' },
689+
}), strict_query_params: true)
690+
691+
get '/events?filter[slug]=%2Ftest&filter[status]=active'
692+
693+
assert_equal 400, last_response.status
694+
assert_match(/filter\[status\]/, last_response.body)
695+
end
696+
697+
it 'continues to support deepObject query params from Rack nested hashes' do
698+
check_parameter = lambda { |env|
699+
assert_equal '/test', env['committee.query_hash']['filter']['slug']
700+
[200, {}, []]
701+
}
702+
703+
@app = new_rack_app_with_lambda(check_parameter, schema: query_param_schema({
704+
'name' => 'filter',
705+
'in' => 'query',
706+
'required' => true,
707+
'style' => 'deepObject',
708+
'explode' => true,
709+
'schema' => {
710+
'type' => 'object',
711+
'required' => ['slug'],
712+
'properties' => {
713+
'slug' => { 'type' => 'string' },
714+
},
715+
},
716+
}))
717+
718+
get '/events?filter[slug]=%2Ftest'
719+
720+
assert_equal 200, last_response.status
721+
end
722+
end
723+
663724
private
664725

665726
def new_rack_app(options = {})
@@ -674,4 +735,23 @@ def new_rack_app_with_lambda(check_lambda, options = {})
674735
run check_lambda
675736
}
676737
end
738+
739+
def query_param_schema(parameter)
740+
Committee::Drivers.load_from_data({
741+
'openapi' => '3.0.3',
742+
'info' => { 'title' => 'test', 'version' => '1.0.0' },
743+
'paths' => {
744+
'/events' => {
745+
'get' => {
746+
'parameters' => [parameter],
747+
'responses' => {
748+
'200' => {
749+
'description' => 'ok',
750+
},
751+
},
752+
},
753+
},
754+
},
755+
}, nil, parser_options: { strict_reference_validation: true })
756+
end
677757
end

0 commit comments

Comments
 (0)