diff --git a/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb b/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb index c315dd92..2c3dbc12 100644 --- a/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb +++ b/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb @@ -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 @@ -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] 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] 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] 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 diff --git a/test/middleware/request_validation_open_api_3_test.rb b/test/middleware/request_validation_open_api_3_test.rb index 43e4aae4..15e98179 100644 --- a/test/middleware/request_validation_open_api_3_test.rb +++ b/test/middleware/request_validation_open_api_3_test.rb @@ -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 = {}) @@ -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