Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def deserialize_params_by_location(raw_params, location)
# If no parameters are defined for this location, return raw params as-is
return raw_params if params_for_location.empty?

raw_params = normalize_raw_params(raw_params, location, params_for_location)

# Collect parameter names that will be deserialized
# This includes both the parameter name and any properties (for exploded objects)
deserialized_keys = Set.new
Expand Down Expand Up @@ -105,6 +107,65 @@ def convert_to_indifferent_hash(hash)
Committee::Utils.indifferent_hash.merge(hash)
end

# Normalize Rack-style nested query hashes into bracket notation when the
# schema expects bracket-named params or deepObject query params.
# Example: { "filter" => { "slug" => "/test" } } => { "filter[slug]" => "/test" }
# @param [Hash] raw_params
# @param [String] location
# @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
# @return [Hash]
def normalize_raw_params(raw_params, location, params_for_location)
return raw_params unless location == 'query'
return raw_params unless raw_params.values.any? { |value| value.is_a?(Hash) }
return raw_params unless requires_query_param_flattening?(params_for_location)

normalized = Committee::Utils.indifferent_hash

raw_params.each do |key, value|
if should_flatten_query_param?(key, value, params_for_location)
flatten_nested_query_param(normalized, key.to_s, value)
else
normalized[key] = value
end
end

normalized
end

# @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
# @return [Boolean]
def requires_query_param_flattening?(params_for_location)
params_for_location.any? { |param_def| param_def.style == 'deepObject' || param_def.name.include?('[') }
end

# @param [String, Symbol] key
# @param [Object] value
# @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
# @return [Boolean]
def should_flatten_query_param?(key, value, params_for_location)
return false unless value.is_a?(Hash)

key_name = key.to_s
params_for_location.any? do |param_def|
param_def.name == key_name || param_def.name.start_with?("#{key_name}[")
end
end

# @param [Hash] result
# @param [String] prefix
# @param [Object] value
# @return [void]
def flatten_nested_query_param(result, prefix, value)
case value
when Hash
value.each do |child_key, child_value|
flatten_nested_query_param(result, "#{prefix}[#{child_key}]", child_value)
end
else
result[prefix] = value
end
end

# Extract and deserialize a single parameter
# @param [OpenAPIParser::Schemas::Parameter] param_def Parameter definition
# @param [Hash] raw_params Raw parameters
Expand Down
87 changes: 87 additions & 0 deletions test/middleware/request_validation_open_api_3_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,44 @@ def app
end
end

describe 'bracket-style query params' do
it 'validates query params declared with bracket notation names' do
check_parameter = lambda { |env|
assert_equal '/test', env['committee.query_hash']['filter[slug]']
refute env['committee.query_hash'].key?('filter')
[200, {}, []]
}

@app = new_rack_app_with_lambda(check_parameter, schema: query_param_schema(bracket_notation_query_parameter))

get '/events?filter[slug]=%2Ftest'

assert_equal 200, last_response.status
end

it 'rejects unknown nested query params with strict_query_params' do
@app = new_rack_app(schema: query_param_schema(bracket_notation_query_parameter), strict_query_params: true)

get '/events?filter[slug]=%2Ftest&filter[status]=active'

assert_equal 400, last_response.status
assert_match(/filter\[status\]/, last_response.body)
end

it 'continues to support deepObject query params from Rack nested hashes' do
check_parameter = lambda { |env|
assert_equal '/test', env['committee.query_hash']['filter']['slug']
[200, {}, []]
}

@app = new_rack_app_with_lambda(check_parameter, schema: query_param_schema(deep_object_query_parameter))

get '/events?filter[slug]=%2Ftest'

assert_equal 200, last_response.status
end
end

private

def new_rack_app(options = {})
Expand All @@ -674,4 +712,53 @@ def new_rack_app_with_lambda(check_lambda, options = {})
run check_lambda
}
end

def query_param_schema(parameter)
Committee::Drivers.load_from_data(query_param_document(parameter), nil, parser_options: { strict_reference_validation: true })
end

def bracket_notation_query_parameter
{
'name' => 'filter[slug]',
'in' => 'query',
'required' => true,
'schema' => { 'type' => 'string' },
}
end

def deep_object_query_parameter
{
'name' => 'filter',
'in' => 'query',
'required' => true,
'style' => 'deepObject',
'explode' => true,
'schema' => {
'type' => 'object',
'required' => ['slug'],
'properties' => {
'slug' => { 'type' => 'string' },
},
},
}
end

def query_param_document(parameter)
{
'openapi' => '3.0.3',
'info' => { 'title' => 'test', 'version' => '1.0.0' },
'paths' => {
'/events' => {
'get' => {
'parameters' => [parameter],
'responses' => {
'200' => {
'description' => 'ok',
},
},
},
},
},
}
end
end